Раздел 46 · Effect: Тихая Академия

Хотя бы раз: гарантии доставки

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке кольцо решило, какой башне идёт письмо. Оставался провод между башнями, и провод теряет. Иногда теряет кадр на пути туда, иногда квитанцию на пути обратно. Отправитель видит одно и то же: тишину. По тишине нельзя понять, что потерялось.

Два генерала

Две армии стоят на холмах по разные стороны долины, в долине враг. Победят, только если ударят одновременно. Связь одна: гонец через долину, и гонца могут перехватить. Первый генерал шлёт: «Бьём на рассвете». Дошёл ли гонец? Он не знает, поэтому ждёт ответа. Второй генерал получил приказ и шлёт: «Понял». Дошёл ли ответ? Теперь не знает второй и ждёт подтверждения подтверждения. Каждый новый гонец переносит неуверенность на другую сторону, и никакое конечное число гонцов её не снимает.

Это задача двух генералов, и у неё нет решения. Для линии башен вывод такой: нельзя гарантировать, что письмо доставлено ровно один раз, если провод теряет. Можно выбрать, какую ошибку терпеть.

  • Не больше раза. Послал и забыл. Письмо может потеряться, зато дубля не будет. Годится для пульса башни, где следующий кадр всё равно придёт через секунду.
  • Хотя бы раз. Шлём, пока не придёт квитанция. Письмо не потеряется, но может прийти дважды: квитанция потерялась, а письмо дошло.
  • Обработано один раз. «Хотя бы раз» плюс башня на том конце, которая узнаёт дубль по номеру и второй раз не пишет. Доставок может быть три, а запись одна.

Третье и есть то, что обычно называют «ровно один раз». Это не свойство провода, а договор двух концов.

Покрути долину сам: ползунок задаёт, какую долю гонцов перехватывают, а переключатель включает и выключает память о номерах на том конце. Без памяти счётчик дублей растёт вместе с повторами. С памятью дублей нет: повторы, которые всё же дошли, уходят в «отсеяно».

Шлём, пока не ответят

Провод в коде линии это сервис с одним методом: послать пакет и узнать, вернулась ли квитанция.

import { Effect, Schedule, Schema } from 'effect';

export interface Packet {
  readonly id: string;
  readonly letter: number;
}

export interface Link {
  readonly send: (packet: Packet) => Effect.Effect<boolean>;
}

export class NoAck extends Schema.TaggedError<NoAck>()('NoAck', {
  id: Schema.String,
  attempts: Schema.Int,
}) {}

export const deliverAtLeastOnce = (link: Link, packet: Packet, times = 5) =>
  link.send(packet).pipe(
    Effect.flatMap((acked) => (acked ? Effect.void : Effect.fail('квитанции нет'))),
    Effect.retry({ schedule: Schedule.spaced('1 second'), times: times - 1 }),
    Effect.mapError(() => new NoAck({ id: packet.id, attempts: times })),
  );

Разберём.

  • send отдаёт boolean, а не ошибку. Тишина провода это обычный ответ «квитанции нет», а не беда эффекта.
  • Effect.flatMap делает из false ошибку. Без этого повторять нечего: Effect.retry повторяет только провалы.
  • Effect.retry с расписанием из урока 73: пауза в секунду и не больше times - 1 повторов. Вместе с первой попыткой это ровно times отправок.
  • После последней попытки ошибка «квитанции нет» становится NoAck с номером пакета и числом попыток. Это честный ответ: пакет мог и дойти, мы просто не знаем.

У пакета есть id. Отправитель кладёт в него один и тот же номер при каждом повторе, иначе на том конце дубль не узнать.

Квитанция только после записи

Теперь тот конец. Башня получает пакет и должна ответить, слать ли квитанцию. Порядок здесь главное во всём уроке: сначала обработать, потом квитанция. Квитанция «увидела» ничего не обещает: башня могла увидеть пакет и упасть, не записав. Отправитель получил квитанцию, перестал слать, и письмо пропало. Квитанция «записала» значит, что повторять больше не нужно.

import { Effect, Ref } from 'effect';

import type { Packet } from './delivery';

export interface Inbox {
  readonly receive: (packet: Packet) => Effect.Effect<boolean>;
}

export const makeInbox = <E>(process: (packet: Packet) => Effect.Effect<void, E>) =>
  Effect.map(
    Ref.make<ReadonlySet<string>>(new Set()),
    (seen): Inbox => ({
      receive: (packet) =>
        Effect.flatMap(Ref.get(seen), (ids) =>
          ids.has(packet.id)
            ? Effect.succeed(true)
            : process(packet).pipe(
                Effect.andThen(Ref.update(seen, (done) => new Set([...done, packet.id]))),
                Effect.as(true),
                Effect.orElseSucceed(() => false),
              ),
        ),
    }),
  );

Три ветки.

  • Номер уже видели. Квитанция true без второй обработки. Отправитель шлёт повтор потому, что прошлая квитанция потерялась, и ему нужна новая. Письмо уже записано.
  • Новый номер, обработка прошла. Номер запоминается после обработки, и квитанция уходит.
  • Новый номер, обработка упала. Номер не запоминается, ответ false. Отправитель не получит квитанции, пришлёт пакет снова, и обработка повторится. Беда не превращается в «принято».

Вместе с deliverAtLeastOnce выходит «обработано один раз»: доставок сколько угодно, записей одна.

Под капотом

В кластере это уже есть

Сущности кластера из прошлого урока говорят по тому же принципу. Сообщение сущности по умолчанию летучее: идёт по сети к раннеру, где сейчас живёт башня, и если раннер упал на полпути, сообщение пропало. Пометка ClusterSchema.Persisted на Rpc меняет это: сообщение сначала ложится в хранилище почтовых ящиков, а уже оттуда уходит к сущности. Упал раннер, шарды переехали к соседу, и сообщение достанется башне на новом месте. Это та же «хотя бы раз»: сообщение может прийти дважды, если раннер упал после обработки, но до отметки. Поэтому в TowerLayer из прошлого этапа Deliver и смотрит на номер письма: дубль не меняет журнал.

Нестабильно. Модуль effect/cluster в 4.1 помечен @stability unstable, вместе с ClusterSchema.Persisted: имена и поведение хранилища почтовых ящиков могут поменяться в минорной версии. Сам код этапа стоит на стабильном ядре (Effect.retry, Schedule, Ref). Урок написан на 4.1.0.

Где тут дыра

const receive = (packet: Packet) =>
  Effect.gen(function* () {
    yield* Ref.update(journal, (letters) => [...letters, packet.letter]);
    return true;
  });

На его примере всё работает: провод не теряет, каждый пакет приходит раз, журнал верный. Теперь провод теряет квитанции. Ринцвальд шлёт пакет, T-7 пишет его в журнал и шлёт квитанцию, квитанция теряется. Ринцвальд шлёт снова, T-7 пишет ещё раз. К утру в журнале одно письмо трижды, и у горожанина три одинаковых счёта за доставку. Повторы без памяти на том конце это не «хотя бы раз», а «сколько повезёт».

Линия, этап 4

Линия учится доставлять по проводу, который теряет. Новый модуль src/delivery.ts: отправка с повтором до квитанции и входящие башни, которые принимают письмо один раз. Тесты гоняют пакеты через сеть, которая по списку теряет то кадр, то квитанцию, и считают отправки и обработки.

Практика

Глубже в архиве

Надёжная доставка без Effect, с очередями, повторами и подтверждениями, разобрана в уроке про надёжную доставку раздела про бэкенд.

Дальше

К вечеру Ринцвальд спокоен: кадр на T-7 дошёл, и записан он один раз. Липвиг приносит из окошка жалобу.

Ключ и двойная запись в следующем уроке.