Когда заходит разговор про эрланг/эликсир, людям обычно приходят на ум три основные ассоциации: легковесные процессы, акторная модель, отказоустойчивость. Эрланг позволял запускать сотни тысяч (при тонкой настройке виртуальной машины — миллионы) «процессов» (того, что потом назвали гринтредами, а еще позже — горутинами) — почти сорок лет назад. Джо Армстронг в своей диссертации полушутя охарактеризовал язык через катахрезу everything is a process. С акторной моделью — понятно, это прямое влияние Алана Кая и его идей насчет everything is an object (sending messages to each other).
Как только вы разделите мир на параллельные компоненты, они смогут общаться друг с другом только посредством отправки сообщений. Это почти биологическая, физическая модель мира. Когда группа людей сидит и разговаривает друг с другом, вы можете представить, что у них в головах есть независимые модели, и они общаются друг с другом с помощью языка. Язык — это сообщения, а то, что находится в их мозгу, — конечный автомат. Это представляется очень естественным способом мышления. — Concurrency in Computing with Joe Armstrong
А вот с отказоустойчивостью всё немного интереснее. Диссертация Армстронга называется «Создание надежных распределенных систем в условиях неизбежности программных ошибок». В отличие от практически всех остальных создателей языков, от экстремиста Матца, который считал, что программист настолько умён, что его вообще не нужно ни в чем ограничивать, — до унылого прагматика Пайка, который выдумал язык для не очень умных студентов, чтобы они хотя бы ничего не испортили, Армстронг жил в реальном мире и понимал, что ошибки будут, и вместо того, чтобы предотвращать их привнесение (что невозможно) — необходимо бороться с последствиями.
Так появился знаменитый слоган «Let It Crash». Вот тут Армстронг его объясняет. Если вкратце, то никто никогда не предлагал вообще никак не обрабатывать ошибки, а вместо этого валиться. Смысл заключается в том, что все ошибки мы всё равно никогда обработать не сможем: часть из-за непредсказуемости поведения сложного кода в пограничных случаях, часть — из-за аппаратных сбоев, над которыми программное обеспечение не властно. Поэтому не нужно пытаться обработать все ошибки (это невозможно) — надо обработать ожидаемые и не бояться, что упав, процесс потянет за собой всё приложение или перестанет предоставлять требуемую функциональность. Это обеспечивается деревьями супервизоров — упав, процесс будет перезапущен в состоянии, известном как «хорошее».
Эта парадигма прекрасно работает (и даже была имитирована в кубере, не очень удачно, но хоть как-то). Работает, но есть одно но.
Долгоживущие процессы с состоянием
Эрланг создавался для телекома. Конечные автоматы процессов там очень простые, типа idle
→ calling
→ in_call
→ idle
. Внутреннее состояние процесса тоже довольно бесхитростное. Упал процесс в середине звонка? — Ну не беда, главное, чтобы телефон абонента не отыквился, поэтому просто перезапустим процесс (и он войдет в начальное состояние idle
).
В современном мире появились бизнес-задачи, требующие долгоживущих процессов с изменяемым состоянием. Например, счётчик (я знаю, что эрланг предоставляет из коробки быстрый и простой в использовании модуль :counters, это просто пример).
И если мы что-то там измеряли, а потом упали — процесс перезапустится, но его накопленный стейт потеряется. Это поведение порождает неимоверные костыли, типа сохранения стейта в стороннее хранилище. И если для счетчика это еще туда-сюда — то просто для горячего кэша — граничащая с безумием паранойя. Эрланг должен уметь решать эту задачу сам. (Я знаю про DETS
, но они — локальны; я также знаю про mnesia
, но её крайне непросто поддерживать в контейнерах). И — повторюсь — должно быть решение, которое не требует превратить эрланг в джаву и тащить за собой какие-то редисы.
Peeper
Именно с целью решить эту задачу раз и навсегда, я вычленил свой код поддержки сохраняемого при перезапусках процесса GenServer
состояния в библиотеку Peeper. И, хотя это решение практически является drop-in заменой обычного GenServer
, я настоятельно не рекомендую использовать её везде, потому что во многих случаях она может замаскировать проблему, замести под ковёр пыль внеплановых перезапусков и сбить с толку разработчика, который видит, что всё отрабатывает нормально (и по тестам тоже), а на деле — каждый процесс перезапускается триста раз в секунду.
Использовать её имеет смысл в том случае, если у вас есть долгоживущий процесс с изменяемым состоянием (самый простой и внятный пример — горячий кэш), к которому вы не хотите прикручивать стороннее хранилище.
Вот как это всё работает:
defmodule MyGenServer do
@moduledoc false
# мы поддерживаем listener, см. ниже
use Peeper.GenServer, listener: Peeper.Impls.Listener
@impl Peeper.GenServer
def init(state), do: {:ok, state}
@impl Peeper.GenServer
def handle_call(:state, _from, state), do: {:reply, state, state}
def handle_call(:raise, _from, _state) do
# тестовый метод для проверки сохранения состояния
raise "boom"
end
@impl Peeper.GenServer
def handle_info(:inc, state), do: {:noreply, state + 1}
@impl Peeper.GenServer
def handle_cast(:inc, state),
do: {:noreply, state, {:continue, :inc}}
# да, handle_continue/2 тоже поддерживается
@impl Peeper.GenServer
def handle_continue(:inc, state),
do: {:noreply, state + 1}
end
Теперь можно немного с ней поиграться.
iex|🌢|2 ▶ {:ok, pid} = MyGenServer.start_link(state: 0, name: MyServer)
{:ok, #PID<0.250.0>}
iex|🌢|3 ▶ Peeper.call(pid, :state)
0
iex|🌢|4 ▶ Peeper.cast(pid, :inc) # handle_cast/2
:ok
iex|🌢|5 ▶ Peeper.send(MyServer, :inc) # handle_info/2
:inc
iex|🌢|6 ▶ Peeper.call(pid, :raise)
11:29:50.248 [error] GenServer MyServer.GenServer terminating
** (RuntimeError) boom
[…]
State: %{state: 2, […]}
[…]
** (exit) exited in: GenServer.call(#PID<0.252.0>, :raise, 5000)
** (EXIT) an exception was raised:
** (RuntimeError) boom
iex|🌢|6 ▶ Peeper.call(pid, :state)
2 # состояние восстановлено
Как видим, поведение вообще никак не отличается от обычного GenServer
, но состояние было восстановлено.
Гарантии
Абсолютных гарантий вам никто не даст, потому, что если потушить любой Supervisor
выше по дереву — чуда не произойдет, и состояние потеряется. Хранить таким образом сумму на банковском счете клиента не сто́ит.
Но в подавляющем большинстве случаев, когда можно более-менее внятно оговорить условия перезапуска всего дерева, и когда внезапная потеря состояния из-за перегрызенного мышами кабеля не настолько критична, этот подход позволяет еще более упростить обработку ошибок.
А уж если все-таки нужно подстраховаться и забэкапить изменения состояния в какой-нибудь event storage — библиотека предоставляет возможность подключить Peeper.Listener, который будет асинхронно вызван при изменениях состояния (и не будет, если состояние из колбэка не менялось), и при остановке процесса. Чтобы вычитать такое состояние при старте из хранилища — придется реализовать в вашем Peeper.GenServer
колбэк init/1.
Библиотека поддерживает hot code upgrade, если в нашу эпоху докера это еще хоть кому-нибудь, кроме меня, интересно.
Удачного сохранения стейта!