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

Ключ и outbox: двойная запись

middle-senior~20 мин

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

В прошлом уроке линия научилась слать, пока не ответят, а башня на том конце научилась узнавать дубль по номеру пакета. Но номер кто-то должен выдать, и выдать один раз. Сегодня два места, где «один раз» ломается: у окошка, куда горожанин стучит дважды, и внутри самого окошка, где письмо пишется в два места.

Ключ идемпотентности

Начнём с горожанина. Повторный щелчок не баг горожанина: сеть задумалась, ответ не пришёл, и он сделал то же, что сделал бы отправитель из прошлого урока, повторил. Значит, окошко должно уметь принять одно и то же письмо дважды и выдать одно письмо.

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

Ключ хранится в той же таблице, что письмо, под ограничением unique:

import { Effect } from 'effect';
import { SqlClient } from 'effect/sql';

export const letters = Effect.gen(function* () {
  const sql = yield* SqlClient.SqlClient;
  yield* sql`create table letters (
    id serial primary key,
    key text not null unique,
    addressee text not null
  )`;
});

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

Две записи, одна беда

Теперь окошко изнутри. Принять письмо значит сделать две вещи: записать его в книгу Почтамта и поставить задание в очередь линии из урока 106, чтобы его отнесли. Книга это база, очередь это другое хранилище. Две записи в две системы называют двойной записью, и у неё нет безопасного порядка.

  • Сначала книга, потом очередь. Окошко упало между ними: письмо в книге есть, а задания нет. Письмо никто не понесёт, горожанин ждёт вечно, книга уверяет, что всё принято.
  • Сначала очередь, потом книга. Упало между ними: задание есть, письма в книге нет. Линия несёт письмо, которого Почтамт не знает, и на вопрос горожанина отвечает «такого письма нет».

Можно перепробовать все хитрости с повторами, но точка падения всегда найдётся между двумя записями, пока их две.

Перетащи точку падения сам: до первой записи, между ними, после второй, для обоих порядков. Ни один порядок не проходит все точки. Третья вкладка включает outbox: падение «между» там тоже есть, но после него письмо доставит следующий проход разносчика.

Одна транзакция и разносчик

Выход в том, чтобы записей снова стало одна. Задание кладут не в очередь, а в ту же базу, в таблицу outbox, той же транзакцией, что письмо. Транзакция из урока 94 делает падение посередине безвредным: письмо и строка outbox появятся обе или ни одна. А в очередь строки переносит отдельный разносчик.

export const acceptLetter = (key: string, to: string) =>
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient;
    return yield* sql.withTransaction(
      Effect.gen(function* () {
        const existing = yield* sql<{ id: number }>`select id from letters where key = ${key}`;
        if (existing[0] !== undefined) return existing[0].id;
        const inserted = yield* sql<{ id: number }>`insert into letters ${sql.insert({ key, addressee: to })} returning id`;
        const id = inserted[0]?.id ?? 0;
        yield* sql`insert into outbox ${sql.insert({ letter_id: id })}`;
        return id;
      }),
    );
  }).pipe(Effect.orDie);
  • Ключ нашёлся: ответ это номер старого письма, и транзакция ничего не пишет.
  • Ключа нет: письмо и строка outbox в одной транзакции. Упадёт что угодно внутри, и откатится всё.
  • Effect.orDie делает беду базы дефектом: горожанину в окошке нечего с ней делать.

Разносчик читает неотправленные строки и публикует их:

export const relay = <E, R>(publish: (row: OutboxRow) => Effect.Effect<void, E, R>) =>
  Effect.gen(function* () {
    const sql = yield* SqlClient.SqlClient;
    const rows = yield* pending;
    for (const row of rows) {
      yield* publish(row);
      yield* Effect.orDie(sql`update outbox set sent = true where id = ${row.id}`);
    }
    return rows.length;
  });

Порядок внутри цикла выбран нарочно: сначала опубликовать, потом отметить. Разносчик может упасть между ними, и это снова двойная запись: очередь и база. Но теперь падение даёт не потерю, а повтор. Строка осталась неотмеченной, и следующий проход опубликует её ещё раз. А дубль на той стороне гасят уже знакомые средства: очередь из урока 106 не ставит задание с тем же id, а входящие из прошлого урока не обрабатывают пакет с тем же номером.

Если publish упал, цикл останавливается, строка остаётся в pending и уйдёт следующим проходом. Ни одна строка не отмечена отправленной, не будучи опубликованной.

Под капотом

Нестабильно. Модуль effect/sql в 4.1 помечен @stability unstable, включая SqlClient, withTransaction, SqlSchema и Migrator: имена и настройки могут поменяться в минорной версии. Урок написан на 4.1.0.

Где тут дыра

const accept = (key: string, to: string) =>
  Effect.gen(function* () {
    const id = yield* writeLetter(key, to);
    yield* enqueue(jobs, { letter: id, to });
    return id;
  });

На его примере работает: окошко не падает, письмо и задание появляются обе. Теперь Гекс перезапускают в полдень, ровно между двумя yield*. Письмо в книге, задания нет. Горожанин не дождался ответа и щёлкает снова, ключ находится, окошко отдаёт номер старого письма, а задания так и нет: письмо никто никогда не понесёт. Ринцвальд меняет строки местами, и в следующий полдень линия несёт письмо, которого нет в книге. Пока записей две, падение между ними найдётся.

Линия, этап 5

Окошко линии перестаёт плодить дубли и терять письма. Новый модуль src/outbox.ts: миграции с ключом под unique, приём письма одной транзакцией и разносчик outbox. Тест поднимает PGlite сам, модуль знает только effect/sql.

Практика

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

Надёжная доставка и outbox без Effect разобраны в уроке про надёжную доставку, события и шины в уроке про CQRS, event sourcing и шины раздела про бэкенд.

Дальше

Липвиг доволен книгой: письмо одно, и задание к нему есть всегда. Тикка заходит с картой линии.

Путь, который помнит, где остановился, в следующем уроке.