Суть Чтение реальных сниппетов event sourcing — пересборка projection, загрузка через snapshot и upcaster — предскажи поведение и выбери сеньорский фикс.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Паттерн живёт или умирает в цикле свёртки, обработчике projection, загрузке через snapshot и upcaster. Прочитай каждый сниппет, предскажи, что он делает в реальных условиях, потом выбери фикс, который сеньор делает первым.
Цель
Потренируйся читать четыре куска кода, что есть в любой event-sourced системе: пересборку read-модели через replay, идемпотентность projection, загрузку через snapshot плюс хвост, и upcasting старой формы события без переписывания истории.
Сниппет 1 — пересборка projection
// пересборка "drop and replay" read-модели балансовasync function rebuildBalances(store: EventStore, db: ReadDb) { await db.exec("TRUNCATE balances"); for await (const e of store.readAll({ from: 0 })) { // все события, по порядку if (e.type === "MoneyDeposited") { await db.exec( "UPDATE balances SET cents = cents + $1 WHERE acct = $2", e.data.cents, e.data.acct, ); } // обработчик MoneyWithdrawn опущен }}
Викторина
Completed
Таблица balances пуста после TRUNCATE. Для счёта, чьё первое событие MoneyDeposited, UPDATE затрагивает ноль строк и баланс не появляется. Каков правильный фикс?
Heads-up Полная пересборка ДОЛЖНА начинаться с пустоты — именно это делает projection одноразовыми и воспроизводимыми. Баг в том, что обработчик никогда не вставляет строку, а не в том, что таблицу очистили.
Heads-up Порядок событий должен быть от старых к новым для корректной свёртки; разворот портит состояние. Пропавшая строка из UPDATE без предшествующего INSERT, а не из порядка.
Heads-up Уникальный индекс ограничивает дубли; он не заставляет UPDATE вставить отсутствующую строку. Нужен UPSERT или обработчик события создания.
Сниппет 2 — обработчик projection при ретраях
async function onEvent(e: Event, db: ReadDb) { // доставка at-least-once: может быть вызвана дважды для одного события await db.exec( "UPDATE counters SET total = total + 1 WHERE name = $1", e.aggregateId, );}
Викторина
Completed
Доставка at-least-once, так что обработчик может выполниться дважды для одного события. В чём баг и каков стандартный фикс?
Heads-up Базы не дедуплицируют по тексту запроса — выполнить тот же инкремент дважды добавит два. At-least-once означает, что идемпотентность обеспечиваешь ты сам.
Heads-up Настоящий exactly-once через сеть и базу в общем недостижим; системы дают at-least-once, а потребителя ты делаешь идемпотентным. Полагаться на exactly-once это ловушка.
Heads-up Ретраи делают дублирующее применение БОЛЕЕ вероятным, а не менее. Фикс это version-checkpoint, делающий повторное применение no-op, а не больше ретраев.
Сниппет 3 — загрузка агрегата через snapshot
async function loadAccount(id: string, store: EventStore): Promise<Account> { const snap = await store.latestSnapshot(id); // может быть null let state = snap ? snap.state : Account.empty(id); const from = snap ? snap.version : 0; for await (const e of store.read(id, { from })) { // БАГ здесь state = apply(state, e); } return state;}
Викторина
Completed
Snapshot захватил состояние до версии N включительно (snap.version === N). Чтение использует from = N. Что идёт не так?
Heads-up apply это шаг свёртки, не идемпотентный — применить MoneyDeposited дважды добавит сумму дважды. Snapshot уже включает версию N, поэтому её надо пропустить.
Heads-up Это сводит на нет весь смысл snapshot, существующих, чтобы ограничить стоимость replay. Фикс — правильная исключающая граница, а не отказ от snapshot.
Heads-up read возвращает события от старых к новым с заданной версии; порядок в норме. Дефект во включающей нижней границе, повторно применяющей версию N.
Какова роль этой функции и какое свойство существенно, чтобы её было безопасно нести бесконечно?
Heads-up Она возвращает преобразованный объект в памяти и никогда не пишет обратно в хранилище; сохранённое событие v1 не тронуто. Переписывание лога нарушило бы append-only.
Heads-up Она производит нормализованное событие, а не строку read-модели. Upcasting происходит на пути чтения хранилища событий, до того как событие дойдёт до projection или агрегатов.
Heads-up Рассыпание дефолтов по каждому потребителю навсегда протекает историей схемы. Централизация в одном протестированном upcaster и есть смысл — доменный код остаётся на текущей форме.
Итог
Любая event-sourced система читается в этих четырёх кусках кода. Пересборка начинается с пустоты и должна реконструировать строки из лога, поэтому обработчики делают UPSERT или чтят события создания, а не предполагают строки. Projection сталкиваются с доставкой at-least-once, поэтому продвигают version-checkpoint на поток в той же транзакции, что и запись, и игнорируют уже виденные версии. Загрузка через snapshot использует исключающую нижнюю границу (версия + 1), чтобы заснапшоченное событие не свернулось повторно. А upcaster это чистые протестированные преобразования, поднимающие старые формы к текущей при чтении, никогда не переписывая сохранённый лог. Читай код, предскажи свёртку, потом чини размещение истины — но не сам лог.