Skip to content

Commit

Permalink
Add pg_xmin_optimistic_locking plugin for optimistic locking for all …
Browse files Browse the repository at this point in the history
…models without database changes

This implements similar support to the optimistic_locking plugin,
but without the need for database changes to add a lock version
column.  Instead, it uses the xmin system column, which is
automatically updated every time the row is updated.

This allows you to load the plugin into a base class and have
all models under the base class support optimistic locking.

Internally, this splits off an optimistic_locking_base internal
plugin from optimistic_locking, makes optimistic_locking and
mssql_optimistic_locking use it, and uses it as the basis
for the new pg_xmin_optimistic_locking plugin.
  • Loading branch information
jeremyevans committed Jul 24, 2023
1 parent d5f9d11 commit c280ff5
Show file tree
Hide file tree
Showing 8 changed files with 410 additions and 80 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== master

* Add pg_xmin_optimistic_locking plugin for optimistic locking for all models without database changes (jeremyevans)

* Recognize the xid PostgreSQL type as an integer type in the jdbc/postgresql adapter (jeremyevans)

* Make set_column_allow_null method reversible in migrations (enescakir) (#2060)
Expand Down
46 changes: 8 additions & 38 deletions lib/sequel/plugins/mssql_optimistic_locking.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,57 +26,27 @@ module Plugins
module MssqlOptimisticLocking
# Load the instance_filters plugin into the model.
def self.apply(model, opts=OPTS)
model.plugin :instance_filters
model.plugin(:optimistic_locking_base)
end

# Set the lock_column to the :lock_column option (default: :timestamp)
# Set the lock column
def self.configure(model, opts=OPTS)
model.lock_column = opts[:lock_column] || :timestamp
model.lock_column = opts[:lock_column] || model.lock_column || :timestamp
end

module ClassMethods
# The timestamp/rowversion column containing the version for the current row.
attr_accessor :lock_column

Plugins.inherited_instance_variables(self, :@lock_column=>nil)
end


module InstanceMethods
# Add the lock column instance filter to the object before destroying it.
def before_destroy
lock_column_instance_filter
super
end

# Add the lock column instance filter to the object before updating it.
def before_update
lock_column_instance_filter
super
end

private

# Add the lock column instance filter to the object.
def lock_column_instance_filter
lc = model.lock_column
instance_filter(lc=>Sequel.blob(get_column_value(lc)))
end

# Clear the instance filters when refreshing, so that attempting to
# refresh after a failed save removes the previous lock column filter
# (the new one will be added before updating).
def _refresh(ds)
clear_instance_filters
super
# Make the instance filter value a blob.
def lock_column_instance_filter_value
Sequel.blob(super)
end

# Remove the lock column from the columns to update.
# SQL Server automatically updates the lock column value, and does not like
# it to be assigned.
def _save_update_all_columns_hash
v = @values.dup
cc = changed_columns
Array(primary_key).each{|x| v.delete(x) unless cc.include?(x)}
v = super
v.delete(model.lock_column)
v
end
Expand Down
51 changes: 9 additions & 42 deletions lib/sequel/plugins/optimistic_locking.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,64 +12,31 @@ module Plugins
# p1 = Person[1]
# p2 = Person[1]
# p1.update(name: 'Jim') # works
# p2.update(name: 'Bob') # raises Sequel::Plugins::OptimisticLocking::Error
# p2.update(name: 'Bob') # raises Sequel::NoExistingObject
#
# In order for this plugin to work, you need to make sure that the database
# table has a +lock_version+ column (or other column you name via the lock_column
# class level accessor) that defaults to 0.
# table has a +lock_version+ column that defaults to 0. To change the column
# used, provide a +:lock_column+ option when loading the plugin:
#
# plugin :optimistic_locking, lock_column: :version
#
# This plugin relies on the instance_filters plugin.
module OptimisticLocking
# Exception class raised when trying to update or destroy a stale object.
Error = Sequel::NoExistingObject

# Load the instance_filters plugin into the model.
def self.apply(model, opts=OPTS)
model.plugin :instance_filters
model.plugin(:optimistic_locking_base)
end

# Set the lock_column to the :lock_column option, or :lock_version if
# that option is not given.
# Set the lock column
def self.configure(model, opts=OPTS)
model.lock_column = opts[:lock_column] || :lock_version
model.lock_column = opts[:lock_column] || model.lock_column || :lock_version
end

module ClassMethods
# The column holding the version of the lock
attr_accessor :lock_column

Plugins.inherited_instance_variables(self, :@lock_column=>nil)
end


module InstanceMethods
# Add the lock column instance filter to the object before destroying it.
def before_destroy
lock_column_instance_filter
super
end

# Add the lock column instance filter to the object before updating it.
def before_update
lock_column_instance_filter
super
end

private

# Add the lock column instance filter to the object.
def lock_column_instance_filter
lc = model.lock_column
instance_filter(lc=>get_column_value(lc))
end

# Clear the instance filters when refreshing, so that attempting to
# refresh after a failed save removes the previous lock column filter
# (the new one will be added before updating).
def _refresh(ds)
clear_instance_filters
super
end

# Only update the row if it has the same lock version, and increment the
# lock version.
def _update_columns(columns)
Expand Down
55 changes: 55 additions & 0 deletions lib/sequel/plugins/optimistic_locking_base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen-string-literal: true

module Sequel
module Plugins
# Base for other optimistic locking plugins
module OptimisticLockingBase
# Load the instance_filters plugin into the model.
def self.apply(model)
model.plugin :instance_filters
end

module ClassMethods
# The column holding the version of the lock
attr_accessor :lock_column

Plugins.inherited_instance_variables(self, :@lock_column=>nil)
end

module InstanceMethods
# Add the lock column instance filter to the object before destroying it.
def before_destroy
lock_column_instance_filter
super
end

# Add the lock column instance filter to the object before updating it.
def before_update
lock_column_instance_filter
super
end

private

# Add the lock column instance filter to the object.
def lock_column_instance_filter
instance_filter(model.lock_column=>lock_column_instance_filter_value)
end

# Use the current value of the lock column
def lock_column_instance_filter_value
public_send(model.lock_column)
end

# Clear the instance filters when refreshing, so that attempting to
# refresh after a failed save removes the previous lock column filter
# (the new one will be added before updating).
def _refresh(ds)
clear_instance_filters
super
end
end
end
end
end

109 changes: 109 additions & 0 deletions lib/sequel/plugins/pg_xmin_optimistic_locking.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen-string-literal: true

module Sequel
module Plugins
# This plugin implements optimistic locking mechanism on PostgreSQL based
# on the xmin of the row. The xmin system column is automatically set to
# the current transaction id whenever the row is inserted or updated:
#
# class Person < Sequel::Model
# plugin :pg_xmin_optimistic_locking
# end
# p1 = Person[1]
# p2 = Person[1]
# p1.update(name: 'Jim') # works
# p2.update(name: 'Bob') # raises Sequel::NoExistingObject
#
# The advantage of pg_xmin_optimistic_locking plugin compared to the
# regular optimistic_locking plugin as that it does not require any
# additional columns setup on the model. This allows it to be loaded
# in the base model and have all subclasses automatically use
# optimistic locking. The disadvantage is that testing can be
# more difficult if you are modifying the underlying row between
# when a model is retrieved and when it is saved.
#
# This plugin may not work with the class_table_inheritance plugin.
#
# This plugin relies on the instance_filters plugin.
module PgXminOptimisticLocking
WILDCARD = LiteralString.new('*').freeze

# Define the xmin column accessor
def self.apply(model)
model.instance_exec do
plugin(:optimistic_locking_base)
@lock_column = :xmin
def_column_accessor(:xmin)
end
end

# Update the dataset to append the xmin column if it is usable
# and there is a dataset for the model.
def self.configure(model)
model.instance_exec do
set_dataset(@dataset) if @dataset
end
end

module ClassMethods
private

# Ensure the dataset selects the xmin column if doing so
def convert_input_dataset(ds)
append_xmin_column_if_usable(super)
end

# If the xmin column is not already selected, and selecting it does not
# raise an error, append it to the selections.
def append_xmin_column_if_usable(ds)
select = ds.opts[:select]

unless select && select.include?(:xmin)
xmin_ds = ds.select_append(:xmin)
begin
columns = xmin_ds.columns!
rescue Sequel::DatabaseConnectionError, Sequel::DatabaseDisconnectError
raise
rescue Sequel::DatabaseError
# ignore, could be view, subquery, table returning function, etc.
else
ds = xmin_ds if columns.include?(:xmin)
end
end

ds
end
end

module InstanceMethods
private

# Only set the lock column instance filter if there is an xmin value.
def lock_column_instance_filter
super if @values[:xmin]
end

# Include xmin value when inserting initial row
def _insert_dataset
super.returning(WILDCARD, :xmin)
end

# Remove the xmin from the columns to update.
# PostgreSQL automatically updates the xmin value, and it cannot be assigned.
def _save_update_all_columns_hash
v = super
v.delete(:xmin)
v
end

# Add an RETURNING clause to fetch the updated xmin when updating the row.
def _update_without_checking(columns)
ds = _update_dataset
rows = ds.clone(ds.send(:default_server_opts, :sql=>ds.returning(:xmin).update_sql(columns))).all
values[:xmin] = rows.first[:xmin] unless rows.empty?
rows.length
end
end
end
end
end
79 changes: 79 additions & 0 deletions spec/adapters/postgres_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5650,3 +5650,82 @@ def c2.name; 'Bar' end
@m1.all.must_equal [{:i1=>1, :a=>3}]
end
end if DB.server_version >= 150000

describe "pg_xmin_optimistic_locking plugin" do
before do
@db = DB
@db.create_table! :items do
primary_key :id
String :name, :size => 20
end
end
after do
@db.drop_table?(:items)
end

it "should not allow stale updates" do
c = Class.new(Sequel::Model(:items))
c.plugin :pg_xmin_optimistic_locking
o = c.create(:name=>'test')
o2 = c.first
xmin = c.dataset.naked.get(:xmin)
xmin.wont_equal nil
xmin.must_equal o.xmin
xmin.must_equal o2.xmin
o.name = 'test2'
o.save
xmin.wont_equal o.xmin
xmin.wont_equal c.dataset.naked.get(:xmin)
proc{o2.save}.must_raise(Sequel::NoExistingObject)
end

it "should work with subclasses" do
c = Class.new(Sequel::Model)
c.plugin :pg_xmin_optimistic_locking
sc = c::Model(:items)
o = sc.create(:name=>'test')
o2 = sc.first
xmin = sc.dataset.naked.get(:xmin)
xmin.wont_equal nil
xmin.must_equal o.xmin
xmin.must_equal o2.xmin
o.name = 'test2'
o.save
xmin.wont_equal o.xmin
xmin.wont_equal sc.dataset.naked.get(:xmin)
proc{o2.save}.must_raise(Sequel::NoExistingObject)
end

it "should allow updates when xmin is not selected" do
c = Class.new(Sequel::Model(:items))
c.plugin :pg_xmin_optimistic_locking
c.create(:name=>'test')
o = c.select_all(:items).first
o2 = c.first
o.xmin.must_be_nil
o.name = 'test2'
o.save
o.xmin.wont_be_nil
o.xmin.must_equal c.dataset.naked.get(:xmin)
proc{o2.save}.must_raise(Sequel::NoExistingObject)
end

it "should not select xmin when selecting from subquery" do
c = Class.new(Sequel::Model(@db[:items].from_self))
c.plugin :pg_xmin_optimistic_locking
@db[:items].insert(:name=>'test')
c.first.values[:xmin].must_be_nil
end

it "should not select xmin when selecting from view" do
begin
@db.create_view(:items_view, @db[:items])
c = Class.new(Sequel::Model(:items_view))
c.plugin :pg_xmin_optimistic_locking
@db[:items].insert(:name=>'test')
c.first.values[:xmin].must_be_nil
ensure
@db.drop_view(:items_view)
end
end
end
Loading

0 comments on commit c280ff5

Please sign in to comment.