Pull to refresh

Долгоживущий процесс и восстановление стейта после падений

Reading time5 min
Views855

Когда заходит разговор про эрланг/эликсир, людям обычно приходят на ум три основные ассоциации: легковесные процессы, акторная модель, отказоустойчивость. Эрланг позволял запускать сотни тысяч (при тонкой настройке виртуальной машины — миллионы) «процессов» (того, что потом назвали гринтредами, а еще позже — горутинами) — почти сорок лет назад. Джо Армстронг в своей диссертации полушутя охарактеризовал язык через катахрезу everything is a process. С акторной моделью — понятно, это прямое влияние Алана Кая и его идей насчет everything is an object (sending messages to each other).

Как только вы разделите мир на параллельные компоненты, они смогут общаться друг с другом только посредством отправки сообщений. Это почти биологическая, физическая модель мира. Когда группа людей сидит и разговаривает друг с другом, вы можете представить, что у них в головах есть независимые модели, и они общаются друг с другом с помощью языка. Язык — это сообщения, а то, что находится в их мозгу, — конечный автомат. Это представляется очень естественным способом мышления. — Concurrency in Computing with Joe Armstrong

А вот с отказоустойчивостью всё немного интереснее. Диссертация Армстронга называется «Создание надежных распределенных систем в условиях неизбежности программных ошибок». В отличие от практически всех остальных создателей языков, от экстремиста Матца, который считал, что программист настолько умён, что его вообще не нужно ни в чем ограничивать, — до унылого прагматика Пайка, который выдумал язык для не очень умных студентов, чтобы они хотя бы ничего не испортили, Армстронг жил в реальном мире и понимал, что ошибки будут, и вместо того, чтобы предотвращать их привнесение (что невозможно) — необходимо бороться с последствиями.

Так появился знаменитый слоган «Let It Crash». Вот тут Армстронг его объясняет. Если вкратце, то никто никогда не предлагал вообще никак не обрабатывать ошибки, а вместо этого валиться. Смысл заключается в том, что все ошибки мы всё равно никогда обработать не сможем: часть из-за непредсказуемости поведения сложного кода в пограничных случаях, часть — из-за аппаратных сбоев, над которыми программное обеспечение не властно. Поэтому не нужно пытаться обработать все ошибки (это невозможно) — надо обработать ожидаемые и не бояться, что упав, процесс потянет за собой всё приложение или перестанет предоставлять требуемую функциональность. Это обеспечивается деревьями супервизоров — упав, процесс будет перезапущен в состоянии, известном как «хорошее».

Эта парадигма прекрасно работает (и даже была имитирована в кубере, не очень удачно, но хоть как-то). Работает, но есть одно но.

Долгоживущие процессы с состоянием

Эрланг создавался для телекома. Конечные автоматы процессов там очень простые, типа idlecallingin_callidle. Внутреннее состояние процесса тоже довольно бесхитростное. Упал процесс в середине звонка? — Ну не беда, главное, чтобы телефон абонента не отыквился, поэтому просто перезапустим процесс (и он войдет в начальное состояние 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, если в нашу эпоху докера это еще хоть кому-нибудь, кроме меня, интересно.

Удачного сохранения стейта!

Tags:
Hubs:
Total votes 6: ↑5 and ↓1+6
Comments4

Articles