Как сохранить изменения моделей для определенных моделей при откате транзакции в атомарном запросе django

У меня есть проект Django с ATOMIC_REQUESTS=True.

При неудачной аутентификации мне нужно сохранить критические данные для попытки входа, вот пример кода:

class LoginSerializer(serializers.Serializer):
  ...
  
  def validate(self, data):
    ...
    user = authenticate(email=email, password=password)
    if user is None:
      failed_login_attempt(email)
      raise serializers.ValidationError("Invalid credentials")


def failed_login_attempt(email):
  user = User.objects.get(email=email)
  user.lock_status = "LOCKED"
  user.save()

  Device.objects.create(user=user, ip="127.0.0.1", fingerprint="some-fingerprint")

  Activity.objects.create(user=user, type="failed_login_attempt")

Ограничения:

Возникновение ValidationError приводит к откату всех изменений (включая обновления user.lock_status, Device и Activity). Такое поведение ожидаемо из-за глобальной транзакции, но мне нужно, чтобы эти изменения сохранялись.

Я не могу удалить ATOMIC_REQUESTS=True, так как это очень важно для других частей приложения.

Гол:

Я хочу убедиться, что функция failed_login_attempt сохраняет изменения, даже если ошибка ValidationError откатывает остальную часть транзакции. Каков наилучший способ сохранить эти критические изменения?

Что я пробовал:

  • Обертывание failed_login_attempt в transaction.atomic() Это не работает, потому что они все еще являются частью внешней транзакции.

  • Отдельная конфигурация базы данных с ATOMIC_REQUESTS=False и пользовательским Database Router, как показано в этом asnwer, однако:

    Это требовало широких разрешений для других моделей (User, Device), что позволило бы осуществлять запись вне глобальной транзакции. Я не мог ограничить этот подход только конкретной функцией (failed_login_attempt), что привело бы к слишком широкому контролю.

Обход ORM и использование сырого SQL connections.cursor() кажется хаком, поэтому я бы предпочел избежать этого.

Похоже, это можно решить, используя декоратор django.db.transaction.non_atomic_requests на каждом представлении, где нужно сохранять изменения.

Этот декоратор отменяет эффект ATOMIC_REQUESTS для данного представления.

См. документацию.

Предисловие: Не обработанные исключения вызывают сбой транзакции.

Если вам нужно такое поведение только для отказа входа в систему, вы можете обернуть вызов сериализатора в try-блок, а затем использовать except-блок для вызова вашей функции журнала и выхода из запроса без выполнения каких-либо других операций.

def view(request):
    serializer_class = LoginSerializer
    
    try:
        serializer_class(request.data).is_valid(raise_exception=True)
        # Do some business operation
    except:
        failed_login_attempt(request.user.email)
        return None

Если у вас достаточно доступа и необходимости, вы можете поместить эту модификацию в промежуточное ПО/бэкенд аутентификации, чтобы не вызывать ее вручную на многих/всех представлениях.

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

Слабость этого подхода заключается в том, что если по какой-либо причине в другом месте произойдет исключение, запись об отказе будет откачена. Если вы ничего не делаете, но возвращаете пустой (или практически пустой) ответ после сохранения записи об отказе, вероятность этого должна быть близка к нулю... но не совсем нулю, так что примите это во внимание.

Кроме этого, у вас есть следующие варианты:

  • Перепишите аутентификацию пользователя, чтобы она не вызывала исключений при сбое, найдите другие способы ее обработки
  • Отдельное подключение к базе данных + роутер, как упоминалось в вашем посте
  • Используйте очередь задач с отдельными рабочими (APSchedule, Celery и т.д.) для записи записей о сбоях, они будут работать вне границы транзакции запроса и, таким образом, не будут затронуты откатами
  • .
  • Абстрагируйте регистратор отказов как собственный микросервис и отправляйте записи журнала по HTTP - они не могут быть откачены транзакцией
  • .
  • Используйте logging для дампа попыток входа в файл (их тоже нельзя откатить). А затем, по желанию, периодически собирайте логи, чтобы превратить их в записи в базе данных, либо вручную в вашем экземпляре django, либо на самостоятельном хостинге вроде ELK-стека, либо через сторонние сервисы вроде Sentry, Datadog, New Relic и т. д.
  • .
Вернуться на верх