Для меня минусом было постоянное чувство хрупкости и неполноценности ORM.
Ещё нужно упомянуть aerich: местами сыроват. Год назад испытал много головной боли в тестах или когда требовался откат миграции. Возвращаться нет желания.
По мере роста логики вы должны тщательно обдумывать, что должно выполняться внутри транзакции, а что — вне её. Откат повлияет на всё, что вы поместите внутрь функции.
Если в рамках одного запроса от пользователя мне требуется проводить какие-то изменения внутри транзакции, а другие - строго вне, то это повод задуматься о переосмыслении api. Но даже если требуется делать именно так, то ведь на то это и слой логики, что я волен определять, какие действия должны быть атомарными, а какие - нет :)
Не говоря уже о странном аргументе tx, который нужно передавать в методы репозитория.
Что думаете насчёт того, чтобы не заморачиваться и просто создавать (где нужно) отдельную функцию, которая принимает на вход tx? В UpdateByID по сути так и происходит, просто вложено в другую функцию.
Паттерн UpdateFn (наше основное решение)
Принцип "на, держи юзера, измени в нём, что нужно, а я пока покурю" интересен. Но, кажется, что не совсем удобный в более сложных случаях. У вас был опыт применения, когда требуются изменения сразу в нескольких таблицах? Как будто в этом случае часть бизнес логики будет проникать в репозиторий...
В моем примере происходит почти то же самое, просто логика вызова асинхронной функции из синхронной спрятана в декораторе.
asyncio.run создает новый цикл событий, выполняет корутину и закрывает цикл событий. Если для таски нужен предварительный сетап (например подключение к базе данных), то это также нужно сделать внутри таски. Поэтому оптимальнее сделать сетап один раз, создать/сохранить event loop и уже в нем запускать таски.
Пробовал на python 3.10 и 3.11, в разных комбинациях, в том числе с разным значением max_concurrent_ops.
Для варианта JSONResponse({}, background=BackgroundTask(...)) результат один и тот же. Как только убираю семафор, зависания исчезают, показатели становятся примерно как у arq, но всё равно ниже на 2-5%.
Теперь про прямой вызов queue.enqueue.
На первый взгляд как будто бы всё нормализуется. Но стоит учесть, что в этом случае искусственно занижается нагрузка от locust (т.к. сначала задача ставится в очередь, и только затем отдается ответ на запрос; через BackgroundTask происходит ровно наоборот).
Если увеличить количество пользователей до 5000, то у saq всё равно сохраняется тенденция к деградации сервиса. При этом у arq всё отлично: RPS выше почти на 30%.
В background task задачи выполняются не последовательно: они стартуют сразу после ответа на запрос. Если задача синхронная, то выполняется в threadpool (anyio.to_thread.run_sync).
По исходному коду не нашел причины, почему следующие задачи бы не выполнялись.
Бенчмарки действительно показывают, что saq работает значительно быстрее, чем arq. Локально цифры тоже подтверждаются. Но чтобы это как-то отразилось в реальной практике, похоже, нужны бешеные нагрузки.
Почему при нагрузочном тестировании saq так странно себя ведет - загадка. Судя по логам, сервер периодически намертво зависает на 5-20 секунд в момент постановки задачи в очередь (в это время FastAPI не может обработать ни один запрос).
Да, изначально одним из минусов было то, что последний релиз arq был в конце 2022 года, но внезапно 1 мая этого года выкатили новую версию. Поэтому пришлось убрать)
Если у вас CRUD'овый интернет магазин, то избыточно. Но как только появляются оплаты, интеграции, квитанции, задачи по расписанию, то без фоновых задач никак.
Даже если эти задачи находятся в отдельных микросервисах (как у крупных проектов), тот же faststream значительно упростит реализацию.
При тестировании замечал, что иногда один из воркеров работает заметно медленнее остальных. Тем не менее, это не мешает ему обработать все 10к задач, даже если остальные воркеры завершили работу десятки секунд назад.
Как указал выше, faststream создан именно для общения между микросервисами. Это подразумевает, что все, кто подписан на топик, должны получить сообщение.
С другой стороны, для разных брокеров есть куча специфичных параметров, которые позволяют гранулированно настроить поведение публикации/обработки сообщений, в том числе отработку только один раз. Но этот момент не совсем очевидный и требует проверки, например встречал похожий issue для NATS.
Если у каждой библиотеки запустить по 5 воркеров, то результат будет таким: arq, saq и celery выполнят все 10к задач, а faststream - в 5 раз больше (потому что одна и та же задача будет выполняться на каждом воркере).
Думаю, у faststream такое поведение из-за того, что в первую очередь он создан для общения между микросервисами, а не для фоновых задач.
Не использовал defer, так как, на мой взгляд, намного проще запрашиваемые поля сразу прокидывать в only, нежели высчитывать разницу запрашиваемых и имеющихся наборов полей, а затем решать, применять only или defer.
В DRF можно создать сериализаторы а-ля GRAPHQL
В DRF пока не силен, буду копать.
Не поверишь. Разрабы класса Model в Django давным давно так же подумали.
Информация о полях модели нам нужна в определенном формате, поэтому и применяем кэширование, чтобы каждый раз не итерироваться по атрибутам _meta. Кстати, m2m поле указывается в Model._meta.many_to_many, а в Model._meta.fields не указывается (или не всегда), хотя там у каждого поля есть булевый признак many_to_many.
Генерация field.name через имя related model не сработает, если в поле переопределено related_name или в модели переопределено default_related_name.
У нас практически в каждой модели указан related_name, и всё работает. Попробовал переопределить default_related_name - снова всё работает. Единственный момент, когда не происходит оптимизация, это переопределение related_query_name, но этот способ для тех, кто точно хочет всё сломать.
Оптимизация не сработает, если в коде не оспользовать обращение к concrete_model вместо прокси.
Не понял, почему оптимизация не должна срабатывать. В проекте наблюдаем одинаково оптимальное количество запросов при обращении что к прокси, что к concrete_model.
Второй баг, это не баг. defer/only не делает join нужных таблиц.
Про join и речи не было. "огромное количество запросов" - это симптом, с которым нужно было разобраться. При реверсивном внешнем ключе возникает такая проблема, а, так сказать, при прямом внешнем ключе, всё работает так, как и ожидается (то есть автоматически достается pk).
Мне очень нравится, использую в своих проектах и всем советую)
Приятно видеть активное развитие FastStream.
Превосходный цикл статей. Огромное спасибо за проделанную работу!
Для меня минусом было постоянное чувство хрупкости и неполноценности ORM.
Ещё нужно упомянуть aerich: местами сыроват. Год назад испытал много головной боли в тестах или когда требовался откат миграции. Возвращаться нет желания.
Если в рамках одного запроса от пользователя мне требуется проводить какие-то изменения внутри транзакции, а другие - строго вне, то это повод задуматься о переосмыслении api. Но даже если требуется делать именно так, то ведь на то это и слой логики, что я волен определять, какие действия должны быть атомарными, а какие - нет :)
Что думаете насчёт того, чтобы не заморачиваться и просто создавать (где нужно) отдельную функцию, которая принимает на вход tx? В UpdateByID по сути так и происходит, просто вложено в другую функцию.
Принцип "на, держи юзера, измени в нём, что нужно, а я пока покурю" интересен. Но, кажется, что не совсем удобный в более сложных случаях. У вас был опыт применения, когда требуются изменения сразу в нескольких таблицах? Как будто в этом случае часть бизнес логики будет проникать в репозиторий...
За что так с celery?..
В моем примере происходит почти то же самое, просто логика вызова асинхронной функции из синхронной спрятана в декораторе.
asyncio.run
создает новый цикл событий, выполняет корутину и закрывает цикл событий. Если для таски нужен предварительный сетап (например подключение к базе данных), то это также нужно сделать внутри таски. Поэтому оптимальнее сделать сетап один раз, создать/сохранить event loop и уже в нем запускать таски.Спасибо за совет!
P.S. Приятно видеть в комментариях отца FastStream :)
Пробовал на python 3.10 и 3.11, в разных комбинациях, в том числе с разным значением max_concurrent_ops.
Для варианта
JSONResponse({}, background=BackgroundTask(...))
результат один и тот же. Как только убираю семафор, зависания исчезают, показатели становятся примерно как у arq, но всё равно ниже на 2-5%.Теперь про прямой вызов queue.enqueue.
На первый взгляд как будто бы всё нормализуется. Но стоит учесть, что в этом случае искусственно занижается нагрузка от locust (т.к. сначала задача ставится в очередь, и только затем отдается ответ на запрос; через
BackgroundTask
происходит ровно наоборот).Если увеличить количество пользователей до 5000, то у saq всё равно сохраняется тенденция к деградации сервиса. При этом у arq всё отлично: RPS выше почти на 30%.
В background task задачи выполняются не последовательно: они стартуют сразу после ответа на запрос. Если задача синхронная, то выполняется в threadpool (anyio.to_thread.run_sync).
По исходному коду не нашел причины, почему следующие задачи бы не выполнялись.
Бенчмарки действительно показывают, что saq работает значительно быстрее, чем arq. Локально цифры тоже подтверждаются.
Но чтобы это как-то отразилось в реальной практике, похоже, нужны бешеные нагрузки.
Почему при нагрузочном тестировании saq так странно себя ведет - загадка. Судя по логам, сервер периодически намертво зависает на 5-20 секунд в момент постановки задачи в очередь (в это время FastAPI не может обработать ни один запрос).
Да, изначально одним из минусов было то, что последний релиз arq был в конце 2022 года, но внезапно 1 мая этого года выкатили новую версию. Поэтому пришлось убрать)
Если у вас CRUD'овый интернет магазин, то избыточно. Но как только появляются оплаты, интеграции, квитанции, задачи по расписанию, то без фоновых задач никак.
Даже если эти задачи находятся в отдельных микросервисах (как у крупных проектов), тот же faststream значительно упростит реализацию.
При тестировании замечал, что иногда один из воркеров работает заметно медленнее остальных. Тем не менее, это не мешает ему обработать все 10к задач, даже если остальные воркеры завершили работу десятки секунд назад.
Как указал выше, faststream создан именно для общения между микросервисами. Это подразумевает, что все, кто подписан на топик, должны получить сообщение.
С другой стороны, для разных брокеров есть куча специфичных параметров, которые позволяют гранулированно настроить поведение публикации/обработки сообщений, в том числе отработку только один раз. Но этот момент не совсем очевидный и требует проверки, например встречал похожий issue для NATS.
Если у каждой библиотеки запустить по 5 воркеров, то результат будет таким: arq, saq и celery выполнят все 10к задач, а faststream - в 5 раз больше (потому что одна и та же задача будет выполняться на каждом воркере).
Думаю, у faststream такое поведение из-за того, что в первую очередь он создан для общения между микросервисами, а не для фоновых задач.
Это про какую библиотеку?
Я тоже сначала восхищался алхимией, пока не стал работать с Django ORM. Мем в блоке P.S. тому подтверждение)
Благодарю за замечания и развернутый ответ.
Не использовал defer, так как, на мой взгляд, намного проще запрашиваемые поля сразу прокидывать в only, нежели высчитывать разницу запрашиваемых и имеющихся наборов полей, а затем решать, применять only или defer.
В DRF пока не силен, буду копать.
Информация о полях модели нам нужна в определенном формате, поэтому и применяем кэширование, чтобы каждый раз не итерироваться по атрибутам _meta.
Кстати, m2m поле указывается в Model._meta.many_to_many, а в Model._meta.fields не указывается (или не всегда), хотя там у каждого поля есть булевый признак many_to_many.
У нас практически в каждой модели указан related_name, и всё работает. Попробовал переопределить default_related_name - снова всё работает. Единственный момент, когда не происходит оптимизация, это переопределение related_query_name, но этот способ для тех, кто точно хочет всё сломать.
Не понял, почему оптимизация не должна срабатывать. В проекте наблюдаем одинаково оптимальное количество запросов при обращении что к прокси, что к concrete_model.
Про join и речи не было. "огромное количество запросов" - это симптом, с которым нужно было разобраться. При реверсивном внешнем ключе возникает такая проблема, а, так сказать, при прямом внешнем ключе, всё работает так, как и ожидается (то есть автоматически достается pk).
Благодарю, познавательно.