-
Notifications
You must be signed in to change notification settings - Fork 3
auto_increment_increment validation #63
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
module GlobalUid | ||
class Allocator | ||
attr_reader :recent_allocations, :max_window_size, :incrementing_by, :connection | ||
|
||
def initialize(incrementing_by:, connection:) | ||
@recent_allocations = [] | ||
@max_window_size = 5 | ||
@incrementing_by = incrementing_by | ||
@connection = connection | ||
validate_connection_increment | ||
end | ||
|
||
def allocate_one(table) | ||
identifier = connection.insert("REPLACE INTO #{table} (stub) VALUES ('a')") | ||
allocate(identifier) | ||
end | ||
|
||
def allocate_many(table, count:) | ||
increment_by = validate_connection_increment | ||
|
||
start_id = connection.insert("REPLACE INTO #{table} (stub) VALUES " + (["('a')"] * count).join(',')) | ||
identifiers = start_id.step(start_id + (count - 1) * increment_by, increment_by).to_a | ||
identifiers.each { |identifier| allocate(identifier) } | ||
identifiers | ||
end | ||
|
||
private | ||
|
||
def allocate(identifier) | ||
recent_allocations.shift if recent_allocations.size >= max_window_size | ||
recent_allocations << identifier | ||
|
||
if !valid_allocation? | ||
db_increment = connection.select_value("SELECT @@auto_increment_increment") | ||
message = "Configured: '#{incrementing_by}', Found: '#{db_increment}' on '#{connection.current_database}'. Recently allocated IDs: #{recent_allocations}" | ||
alert(InvalidIncrementException.new(message)) | ||
end | ||
vanchi-zendesk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
identifier | ||
end | ||
|
||
def valid_allocation? | ||
recent_allocations[1..-1].all? do |identifier| | ||
(identifier > recent_allocations[0]) && | ||
(identifier - recent_allocations[0]) % incrementing_by == 0 | ||
end | ||
end | ||
mriddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
mriddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
def validate_connection_increment | ||
db_increment = connection.select_value("SELECT @@auto_increment_increment") | ||
|
||
if db_increment != incrementing_by | ||
alert(InvalidIncrementException.new("Configured: '#{incrementing_by}', Found: '#{db_increment}' on '#{connection.current_database}'")) | ||
end | ||
|
||
db_increment | ||
end | ||
|
||
def alert(exception) | ||
if GlobalUid::Base.global_uid_options[:suppress_increment_exceptions] | ||
GlobalUid::Base.notify(exception, exception.message) | ||
else | ||
raise exception | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,9 +11,10 @@ class Base | |
:connection_retry => 10.minutes, | ||
:notifier => Proc.new { |exception, message| ActiveRecord::Base.logger.error("GlobalUID error: #{exception.class} #{message}") }, | ||
:query_timeout => 10, | ||
:increment_by => 5, # This will define the maximum number of servers that you can have | ||
:increment_by => 5, # This will define the maximum number of servers that you can have | ||
:disabled => false, | ||
:per_process_affinity => true | ||
:per_process_affinity => true, | ||
:suppress_increment_exceptions => false | ||
} | ||
|
||
def self.servers | ||
|
@@ -94,6 +95,7 @@ def self.disconnect! | |
|
||
def self.setup_connections! | ||
connection_timeout = self.global_uid_options[:connection_timeout] | ||
increment_by = self.global_uid_options[:increment_by] | ||
|
||
if self.servers.nil? | ||
self.servers = init_server_info | ||
|
@@ -107,9 +109,18 @@ def self.setup_connections! | |
if info[:new?] || ( info[:retry_at] && Time.now > info[:retry_at] ) | ||
info[:new?] = false | ||
|
||
connection = new_connection(info[:name], connection_timeout) | ||
info[:cx] = connection | ||
info[:retry_at] = Time.now + self.global_uid_options[:connection_retry] if connection.nil? | ||
begin | ||
connection = new_connection(info[:name], connection_timeout) | ||
info[:cx] = connection | ||
if connection.nil? | ||
info[:retry_at] = Time.now + self.global_uid_options[:connection_retry] | ||
else | ||
info[:allocator] = Allocator.new(incrementing_by: increment_by, connection: connection) | ||
end | ||
rescue InvalidIncrementException => e | ||
notify e, "#{e.message}" | ||
info[:cx] = nil | ||
end | ||
end | ||
end | ||
|
||
|
@@ -160,9 +171,9 @@ def self.get_connections | |
|
||
def self.get_uid_for_class(klass) | ||
with_connections do |connection| | ||
server = self.servers.find { |s| connection.current_database.include?(s[:name]) } | ||
bquorning marked this conversation as resolved.
Show resolved
Hide resolved
|
||
Timeout.timeout(self.global_uid_options[:query_timeout], TimeoutException) do | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separate issue that we should come back for later: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ggrossman perhaps we should have the alloc server connections set the That will remove the need for us to manage a timeout here, and the bad mojo that comes with it. E.g. in the database.yml test_id_server_1:
<<: *MYSQL
database: global_uid_test_id_server_1
variables:
auto_increment_increment: 5
auto_increment_offset: 1
max_execution_time: 10
test_id_server_2:
<<: *MYSQL
database: global_uid_test_id_server_2
variables:
auto_increment_increment: 5
auto_increment_offset: 2
max_execution_time: 10 That avoids the need for this gem to mess around with connection variables too, giving the client complete control. EDIT: Ah, nvm. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good idea. I'm in favor of that. We should document that the alloc server connections should definitely declare an appropriate There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it could wait for another PR but it's a good idea to get rid of |
||
id = connection.insert("REPLACE INTO #{klass.global_uid_table} (stub) VALUES ('a')") | ||
return id | ||
return server[:allocator].allocate_one(klass.global_uid_table) | ||
end | ||
end | ||
raise NoServersAvailableException, "All global UID servers are gone!" | ||
|
@@ -171,10 +182,9 @@ def self.get_uid_for_class(klass) | |
def self.get_many_uids_for_class(klass, count) | ||
return [] unless count > 0 | ||
with_connections do |connection| | ||
server = self.servers.find { |s| connection.current_database.include?(s[:name]) } | ||
Timeout.timeout(self.global_uid_options[:query_timeout], TimeoutException) do | ||
increment_by = connection.select_value("SELECT @@auto_increment_increment") | ||
start_id = connection.insert("REPLACE INTO #{klass.global_uid_table} (stub) VALUES " + (["('a')"] * count).join(',')) | ||
return start_id.step(start_id + (count-1) * increment_by, increment_by).to_a | ||
return server[:allocator].allocate_many(klass.global_uid_table, count: count) | ||
end | ||
end | ||
raise NoServersAvailableException, "All global UID servers are gone!" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
# frozen_string_literal: true | ||
require_relative '../test_helper' | ||
|
||
describe GlobalUid::Allocator do | ||
let(:connection) { mock('connection') } | ||
let(:increment_by) { 10 } | ||
let(:allocator) { GlobalUid::Allocator.new(incrementing_by: increment_by, connection: connection) } | ||
let(:table) { mock('table_class').class } | ||
|
||
before do | ||
restore_defaults! | ||
connection.stubs(:current_database).returns('database_name') | ||
end | ||
|
||
describe 'with a configured increment_by that differs from the connections auto_increment_increment' do | ||
it 'raises an exception to prevent erroneous ID allocation' do | ||
connection.stubs(:select_value).with('SELECT @@auto_increment_increment').returns(50) | ||
exception = assert_raises(GlobalUid::InvalidIncrementException) do | ||
mriddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
GlobalUid::Allocator.new(incrementing_by: 10, connection: connection) | ||
end | ||
|
||
assert_equal("Configured: '10', Found: '50' on 'database_name'", exception.message) | ||
end | ||
end | ||
|
||
describe 'with a configured increment_by that matches the connections auto_increment_increment' do | ||
before do | ||
connection.stubs(:select_value).with('SELECT @@auto_increment_increment').returns(increment_by) | ||
end | ||
|
||
describe '#allocate_one' do | ||
it 'allocates IDs, maintaining a small rolling selection of IDs for comparison' do | ||
[10, 20, 30, 40, 50, 60, 70, 80].each do |id| | ||
connection.expects(:insert).returns(id) | ||
allocator.allocate_one(table) | ||
end | ||
|
||
assert_equal(5, allocator.max_window_size) | ||
mriddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
assert_equal([40, 50, 60, 70, 80], allocator.recent_allocations) | ||
end | ||
|
||
describe 'gap between ID not divisible by increment_by' do | ||
it 'raises an error' do | ||
connection.expects(:insert).returns(20) | ||
allocator.allocate_one(table) | ||
|
||
connection.stubs(:select_value).with('SELECT @@auto_increment_increment').returns(5) | ||
connection.expects(:insert).returns(25) | ||
exception = assert_raises(GlobalUid::InvalidIncrementException) do | ||
allocator.allocate_one(table) | ||
end | ||
|
||
assert_equal("Configured: '10', Found: '5' on 'database_name'. Recently allocated IDs: [20, 25]", exception.message) | ||
mriddle marked this conversation as resolved.
Show resolved
Hide resolved
|
||
end | ||
end | ||
|
||
describe 'ID value does not increment upwards' do | ||
it 'raises an error' do | ||
connection.expects(:insert).returns(20) | ||
allocator.allocate_one(table) | ||
|
||
connection.expects(:insert).returns(20 - increment_by) | ||
exception = assert_raises(GlobalUid::InvalidIncrementException) do | ||
allocator.allocate_one(table) | ||
end | ||
|
||
assert_equal("Configured: '10', Found: '10' on 'database_name'. Recently allocated IDs: [20, 10]", exception.message) | ||
end | ||
end | ||
end | ||
|
||
describe '#allocate_many' do | ||
it 'allocates IDs, maintaining a small rolling selection of IDs for comparison' do | ||
connection.expects(:insert) | ||
.with("REPLACE INTO Mocha::Mock (stub) VALUES ('a'),('a'),('a'),('a'),('a'),('a'),('a'),('a')") | ||
.returns(10) | ||
allocator.allocate_many(table, count: 8) | ||
|
||
assert_equal(5, allocator.max_window_size) | ||
assert_equal([40, 50, 60, 70, 80], allocator.recent_allocations) | ||
end | ||
|
||
describe 'with a configured increment_by that differs from the active connection auto_increment_increment' do | ||
it 'raises an error' do | ||
connection.stubs(:select_value).with('SELECT @@auto_increment_increment').returns(5) | ||
connection.expects(:insert).never | ||
exception = assert_raises(GlobalUid::InvalidIncrementException) do | ||
allocator.allocate_many(table, count: 8) | ||
end | ||
|
||
assert_equal("Configured: '10', Found: '5' on 'database_name'", exception.message) | ||
end | ||
end | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a breaking change as any client upgrading that doesn't have their
GlobalUid::Base.global_uid_options[:increment_by]
set to be the same value of theauto_increment_increment
within the database will start to see exceptions (unless suppressed). This value was previously only used in this check