require 'digest' class SharedResourceIdAllocator SPACES = { 'tags' => ['tags', 'external_tags'], 'wiki' => ['tag_descriptions', 'tag_groups'], 'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze HISTORY_IDS = { 'tags' => [ ['tag_versions', 'tag_id'], ['nico_tag_versions', 'tag_id'], ['tag_description_versions', 'tag_id']], 'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze LOCK_TIMEOUT = 10 def self.assign! record space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) } raise ArgumentError, 'Unknown shared ID resource' unless tables connection = record.class.connection transaction = record.class.current_transaction unless transaction.open? raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' end database = connection.pool.db_config.database name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }" quoted_name = connection.quote(name) acquired = record.class.uncached do connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })") end unless acquired.to_i == 1 raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }" end release = lambda do result = record.class.uncached do connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })") end unless result.to_i == 1 raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }" end end begin transaction.after_commit(&release) transaction.after_rollback(&release) rescue StandardError release.call raise end # 明示-tic な Id. 指定は許可する. return if record.id.present? next_id = record.class.uncached do historical_max = HISTORY_IDS.fetch(space, []).map { |table, column| quoted_table = connection.quote_table_name(table) quoted_column = connection.quote_column_name(column) connection.select_value("SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i }.max.to_i [next_ids(connection, tables).max, historical_max + 1].max end record.id = next_id end def self.next_ids connection, tables expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i begin connection.execute('SET SESSION information_schema_stats_expiry = 0') quoted_tables = tables.map { connection.quote(_1) }.join(', ') rows = connection.select_all(<<~SQL).to_a SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables }) SQL persistent = rows.all? do |row| row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive? end unless rows.size == tables.size && persistent raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required' end rows.map { _1.fetch('AUTO_INCREMENT').to_i } ensure connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }") end end private_class_method :next_ids end