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

Транзакции: TxRef и Effect.tx

middle-senior~20 мин

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

Ref.modify из урока 65 делает проверку и запись одним шагом, но только для одной ячейки. Перевод квоты трогает две: убавить на холме, прибавить у нас. И проверку «хватает ли» нельзя отделить от перевода. Нужен способ сделать несколько шагов над несколькими ячейками так, будто это один шаг.

Два Ref подряд

const hill = yield* Ref.make(10);
const ours = yield* Ref.make(0);
const take = (amount: number) =>
  Effect.gen(function* () {
    if ((yield* Ref.get(hill)) < amount) return 'не хватило';
    yield* Effect.yieldNow;
    yield* Ref.update(hill, (n) => n - amount);
    yield* Ref.update(ours, (n) => n + amount);
    return 'взял';
  });
const answers = yield* Effect.all([take(10), take(10)], { concurrency: 'unbounded' });
{ answers: [ 'взял', 'взял' ], hill: -10, ours: 20 }

Обе смены посмотрели, увидели десять, решили, что хватает, и обе взяли. Каждое обновление в отдельности честное. Нечестна связка: проверка, потом два обновления, а между ними чужие шаги. Это не исправить заменой update на modify: ячеек две, а modify знает об одной.

Транзакция

Транзакция собирает шаги над несколькими ячейками в один. В Effect для этого ячейки TxRef и обёртка Effect.tx. Это то, что в Effect 3 называлось STM:

export const transfer = (
  from: TxRef.TxRef<number>,
  to: TxRef.TxRef<number>,
  amount: number,
): Effect.Effect<void> =>
  Effect.tx(
    Effect.gen(function* () {
      const balance = yield* TxRef.get(from);
      if (balance < amount) return yield* Effect.txRetry;
      yield* TxRef.update(from, (value) => value - amount);
      yield* TxRef.update(to, (value) => value + amount);
    }),
  );

Внутри Effect.tx всё как в обычном генераторе: прочитать, проверить, записать. Снаружи это один шаг. Две смены одновременно переводят по десять при остатке в десять:

{
  before: { hill: 0, ours: 10, done: false },
  after: { hill: 0, ours: 20 }
}

Первый перевод прошёл. Второй увидел ноль и не ушёл в минус. Он и не упал: он ждёт. Это делает Effect.txRetry: «сейчас не получится, разбуди меня, когда что-то из прочитанного изменится». Через сто миллисекунд холм пополнили на десять, и ждавший перевод проснулся, перепроверил и прошёл. В итоге холм пуст, у нас двадцать, и ни в одной ведомости нет минуса.

Оптимистично

Как Effect делает так, что транзакции не мешают друг другу? Он не запирает ячейки. Каждая транзакция работает со своей копией: читает, считает, записывает в черновик. В конце смотрит, не поменял ли кто-то ячейки, которые она читала. Не поменял, черновик становится правдой одним шагом. Поменял, черновик выбрасывают, и транзакция идёт заново, с новыми значениями. Это оптимистичная конкурентность: мешают редко, поэтому дешевле иногда повторить, чем всегда запирать.

Попробуй сам: две записи в одно и то же, и кто из них переделывает работу.

Из этого правило: внутри Effect.tx только чтение и запись TxRef. Отправку по проводу, запись в журнал, вопрос соседу туда класть нельзя: транзакцию могут повторить, и строка уйдёт дважды. Тип об этом позаботится наполовину: Effect.tx убирает из R только Transaction, а всё остальное, что ты туда положил, останется в типе, и это повод задуматься.

Под капотом

Каждый TxRef хранит значение и номер версии. Транзакция ведёт журнал: какие ячейки прочла и с какой версией, что хочет записать. В конце, одним неделимым шагом, она сверяет версии прочитанного. Совпали, записывает всё и увеличивает версии. Не совпали, начинает заново. txRetry не начинает заново сразу: он засыпает, пока не изменится версия хоть одной прочитанной ячейки. Поэтому ждущий перевод ничего не тратит, пока холм не пополнят, как колокольчик из урока 66.

Где тут дыра

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

Башня, этап 34

У башен Академии общая квота строк. Перевод квоты атомарен: сумма не меняется, в минус никто не уходит, а перевод, которому не хватает, ждёт пополнения.

Практика

Дальше

Пондер долго смотрит на трубки Гекса.

Как устроен исполнитель файберов, разберём в следующем уроке.