Запрос Rails Postgres не может вернуть новые записи - PullRequest
0 голосов
/ 31 января 2019

Предполагается, что этот код Rails предотвратит повторную запись сервером записей в течение 20 секунд:

@transit = Transit.new(tag: params[:tag])
if Transit.where(tag: @transit.tag).where("created_at > ?", 20.seconds.ago).first
  logger.warn "Duplicate tag"
else
  @transit.save!
end

Однако это не работает.Я могу видеть в своей производственной базе данных (размещенной на Heroku) две разные записи, созданные с одинаковым тегом с интервалом в 10 секунд.

Журналы показывают, что по второму запросу выполняется правильный запрос, но он не возвращает результатов и в любом случае сохраняет новую запись.

Почему это происходит?Я думал, что уровень изоляции Postgres по умолчанию read_committed предотвратит это.Запрос, который не возвращает записей, должен пропустить кеш SQL Rails.Журналы показывают, что оба запроса были обработаны одной и той же WEB.1 Dyno на Heroku, и мой Puma.rb настроен на 4 рабочих и 5 потоков.

Чего мне не хватает?

Вотдве записи в БД:

=> #<Transit id: 1080116, tag: 33504, 
             created_at: "2019-01-30 12:36:11", 
             updated_at: "2019-01-30 12:41:23">

=> #<Transit id: 1080115, tag: 33504, 
             created_at: "2019-01-30 12:35:56", 
             updated_at: "2019-01-30 12:35:56">

Журнал первой вставки:

30 Jan 2019 07:35:56.203132 <190>1 2019-01-30T12:35:56.050681+00:00 app web.1 - - [1m [36m (0.8ms) [0m [1mBEGIN [0m
30 Jan 2019 07:35:56.203396 <190>1 2019-01-30T12:35:56.055097+00:00 app web.1 - - [1m [35mSQL (1.0ms) [0m INSERT INTO "transits" ("tag", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"
30 Jan 2019 07:35:56.269133 <190>1 2019-01-30T12:35:56.114572+00:00 app web.1 - - [1m [36m (2.0ms) [0m [1mCOMMIT [0m

Журнал запроса непосредственно перед вставкой дубликата:

30 Jan 2019 07:36:12.160359 <190>1 2019-01-30T12:36:11.863973+00:00 app web.1 - - [1m [35mTransit Load (5.1ms) [0m SELECT "transits".* FROM "transits" WHERE "transits"."tag" = 33504 AND created_at > '2019-01-30 12:35:51.846431' ORDER BY "transits"."id" ASC LIMIT 1

А вот уровень изоляции транзакции postgres, который следует пояснить для другого соединения, открытого после возникновения этой проблемы:

SHOW default_transaction_isolation;

 default_transaction_isolation 
-------------------------------
 read committed
(1 row)

Ответы [ 3 ]

0 голосов
/ 31 января 2019

Один из способов предотвращения дублирования в Rails - это проверки: Правильный способ предотвращения дублирования записей в Rails

Однако ваш критерий более сложен, так как он охватывает несколько строк.Я считаю, что ваш критерий заключается в том, чтобы не допускать запись транзитной записи, если последняя транзитная запись была создана менее 20 секунд назад.Это правильно?

Попытка применить ограничение, которое включает просмотр данных из многих строк, здесь упоминается как нежелательная: Подзапросы SQL в проверочном ограничении

Триггер можетбыть использованы для обеспечения соблюдения вашего ограничения на уровне базы данных.Можно было нажать на курок в исключении.Есть драгоценный камень под названием HairTrigger, который может быть полезен, не уверен.

Принимая идеи отсюда: https://karolgalanciak.com/blog/2016/05/06/when-validation-is-not-enough-postgresql-triggers-for-data-integrity/

Пример с триггером Postgresql:

bin/rails generate model transit tag:text
rails generate migration add_validation_trigger_for_transit_creation

class AddValidationTriggerForTransitCreation < ActiveRecord::Migration[5.2]
  def up
    execute <<-CODE
      CREATE FUNCTION validate_transit_create_time() returns trigger as $$
      DECLARE
      age int;
      BEGIN
        age := (select extract(epoch from current_timestamp - t.created_at)
        from transits t
        where t.tag = NEW.tag
        and t.id in (select id from transits u
           where u.id = t.id
           and u.tag = t.tag
           and u.created_at = (select max(v.created_at) from transits v where v.tag = u.tag)
        ));
        IF (age < 20) THEN
          RAISE EXCEPTION 'created_at too early: %', NEW.created_at;
        END IF;
        RETURN NEW;
      END;
      $$ language plpgsql;

      CREATE TRIGGER validate_transit_create_trigger BEFORE INSERT OR UPDATE ON transits
      FOR EACH ROW EXECUTE PROCEDURE validate_transit_create_time();
    CODE
  end

  def down
    execute <<-CODE
    drop function validate_transit_create_time() cascade;
    CODE
  end
end


user1@debian8 /home/user1/rails/dup_test > ../transit_test.rb ; sleep 20; ../transit_test.rb 

dup_test_development=> select * from transits;
 id  |   tag    |         created_at         |         updated_at         
-----+----------+----------------------------+----------------------------
 158 | test_tag | 2019-01-31 18:38:10.115891 | 2019-01-31 18:38:10.115891
 159 | test_tag | 2019-01-31 18:38:30.609125 | 2019-01-31 18:38:30.609125
(2 rows)

Вот часть нашего запроса, которая дает последнюю транзитную запись с нашим тегом

dup_test_development=> select * from transits t
where t.tag = 'test_tag' and t.id in
(select id from transits u where u.id = t.id and u.tag = t.tag and u.created_at =
(select max(v.created_at) from transits v where v.tag = u.tag));

 id  |   tag    |         created_at         |         updated_at         
-----+----------+----------------------------+----------------------------
 159 | test_tag | 2019-01-31 18:38:30.609125 | 2019-01-31 18:38:30.609125
(1 row)

Изменение, чтобы дать разницу между current_timestamp (сейчас) и последней транзитной записью с нашим тегом.Эта разница является интервалом в postgresql.Использование UTC для сопоставления с Rails:

dup_test_development=> select current_timestamp at time zone 'utc' - created_at
from transits t  where t.tag = 'test_tag' and t.id in
(select id from transits u where u.id = t.id and u.tag = t.tag and u.created_at =
(select max(v.created_at) from transits v where v.tag = u.tag));
    ?column?     
-----------------
 00:12:34.146536
(1 row)

Добавление выдержки (эпохи) для преобразования этого значения в секунды:

dup_test_development=> select extract(epoch from current_timestamp at time zone 'utc' - created_at)
from transits t  where t.tag = 'test_tag' and t.id in
(select id from transits u where u.id = t.id and u.tag = t.tag and u.created_at =
(select max(v.created_at) from transits v where v.tag = u.tag));
 date_part  
------------
 868.783503
(1 row)

Мы сохраняем секунды как возраст, а если возраст <20,мы поднимаем исключение базы данных </p>

Выполнение 2 вставок со второй задержкой менее 20:

user1@debian8 /home/user1/rails/dup_test > ../transit_test.rb ; sleep 5; ../transit_test.rb 
#<ActiveRecord::StatementInvalid: PG::RaiseException: ERROR:  created_at too early: 2019-01-31 18:54:48.95695
: INSERT INTO "transits" ("tag", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id">
"ERROR:  created_at too early: 2019-01-31 18:54:48.95695\n"

Короткий тест вне рельсов:

#!/usr/bin/env ruby

require 'active_record'
require 'action_view'

path = "/home/user1/rails/dup_test/app/models"
require "#{path}/application_record.rb"
Dir.glob(path + "/*.rb").sort.each do | file |
  require file
end

ActiveRecord::Base.establish_connection(
  :adapter => "postgresql",
  :database  => 'dup_test_development',
  encoding: "unicode",
  username: "user1",
  password: nil
)
class Test
  def initialize()
  end
  def go()
    begin
      t = Transit.new(tag: 'test_tag')
      t.save
    rescue ActiveRecord::StatementInvalid => e
      p e
      p e.cause.message
    end
  end
end

def main
  begin
    t = Test.new()
    t.go()
  rescue Exception => e
    puts e.message
  end
end

main

Использование чего-то вроде Redisбыло упомянуто - может быть лучше для производительности

0 голосов
/ 19 июня 2019

Я считаю, что это была проблема параллелизма.

Транзакции Rails продолжаются асинхронно после возврата ActiveRecord.Всякий раз, когда для фиксации требуется 15 секунд, это приведет к этой проблеме.Это долго и маловероятно, но возможно.

Я не могу доказать, что это то, что произошло, но, похоже, это единственное объяснение.Для предотвращения этого потребуется хранимая процедура в дБ или, как предложено @PhilipWright, или распределенная блокировка, как вы и @kwerle.

0 голосов
/ 31 января 2019

Это то, для чего нужно тестирование.

class Transit <  ActiveRecord::Base
  def new_transit(tag: tag)
  <your code>
  end
end

Вы тестируете код:

  test 'it saves once' do
    <save it once.  check the count, etc>
  end

  test 'it does not save within 10 seconds' do
    <save it once.  Set the created at to 10 seconds ago.  try to save again.  check the count, etc>
  end

и т. Д.

ps Подумайте об использовании redis или чего-то подобного.В противном случае вы хотите сделать что-то вроде блокировки таблицы, чтобы убедиться, что вы не наступаете на себя.И вы, вероятно, не хотите делать блокировки таблиц.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...