В прошлом уроке линия научилась слать, пока не ответят, а башня на том конце научилась узнавать дубль по номеру пакета. Но номер кто-то должен выдать, и выдать один раз. Сегодня два места, где «один раз» ломается: у окошка, куда горожанин стучит дважды, и внутри самого окошка, где письмо пишется в два места.
Ключ идемпотентности
Начнём с горожанина. Повторный щелчок не баг горожанина: сеть задумалась, ответ не пришёл, и он сделал то же, что сделал бы отправитель из прошлого урока, повторил. Значит, окошко должно уметь принять одно и то же письмо дважды и выдать одно письмо.
Операция, которую можно повторить без нового результата, называется идемпотентной. Принять письмо по своей природе не идемпотентно: каждый раз новое письмо. Делают так: страница при открытии формы выдумывает ключ, случайную строку, и шлёт её с письмом. Окошко ищет письмо с таким ключом. Нашло, и отвечает номером найденного. Не нашло, и пишет новое. Два щелчка, один ключ, одно письмо.
Ключ хранится в той же таблице, что письмо, под ограничением 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 и шины раздела про бэкенд.
Дальше
Липвиг доволен книгой: письмо одно, и задание к нему есть всегда. Тикка заходит с картой линии.
Путь, который помнит, где остановился, в следующем уроке.