В прошлом уроке письмо надёжно ложилось в книги и в очередь: окошко не теряло его и не писало дважды. Очередь из урока 106 выдаёт задание воркеру. Дальше начинается путь, и путь длинный: перегон до T-1, ночь, перегон до T-2, ночь, и так до адресата. Воркер, который держит этот путь в памяти, держит его ровно до первого перезапуска.
Нужен процесс, который помнит, где остановился. Не воркер, а запись о пути, которую можно продолжить на другой машине, через час или через неделю. Такой процесс называется workflow.
Перегон, который помнят
Начнём с одного перегона. Башня T-2 принимает письмо через сервис Hops и иногда отказывает:
export class HopFailed extends Schema.TaggedError<HopFailed>()('HopFailed', {
tower: Schema.String,
}) {}
export class Hops extends Context.Service<
Hops,
{ readonly relay: (letter: number, tower: string) => Effect.Effect<void, HopFailed> }
>()('liniya/Hops') {}
В обычном коде перегон это вызов hops.relay(letter, 'T-2'). Запустили, получили итог, забыли. Если воркер упал после перегона, но до следующего шага, при перезапуске перегон пойдёт снова: письмо придёт на T-2 второй раз.
Активность это перегон, который движок помнит:
yield* Activity.make({
name: `hop ${tower}`,
error: HopFailed,
execute: hops.relay(letter, tower).pipe(Effect.retry({ times: 2, schedule: Schedule.spaced('1 second') })),
});
nameэто имя шага в записи пути. По нему движок узнаёт, что шаг уже был. Поэтому имя должно быть одним и тем же при каждом прогоне:hop T-2, а неhop ${Date.now()}.errorэто схема беды. Итог активности движок пишет в журнал, а значит, кодирует: и успех, и ошибку.executeэто сам перегон. Повторы из урока 73 живут внутри: два повтора раз в секунду, и только если все три попытки провалились, перегон считается проваленным.
Записанный итог переживает перезапуск. Активность, которая один раз закончилась, второй раз не начнётся.
Весь путь
Путь письма собирается из перегонов:
export const CarryLetter = Workflow.make('CarryLetter', {
payload: { letter: Schema.Int, path: Schema.Array(Schema.String) },
success: Schema.String,
error: HopFailed,
idempotencyKey: ({ letter }) => `letter-${letter}`,
});
export const Arrived = DurableDeferred.make('arrived', { success: Schema.String });
export const CarryLetterLayer = CarryLetter.toLayer(
Effect.fn(function* ({ letter, path }) {
const hops = yield* Hops;
for (const tower of path) {
yield* Activity.make({
name: `hop ${tower}`,
error: HopFailed,
execute: hops.relay(letter, tower).pipe(Effect.retry({ times: 2, schedule: Schedule.spaced('1 second') })),
});
}
const by = yield* DurableDeferred.await(Arrived);
return `письмо ${letter} принял ${by}`;
}),
);
Разберём, что тут нового.
Workflow.makeописывает путь, какRpc.makeописывал вызов в уроке 93: имя, схема входа, схема успеха, схема беды. Всё это схемы, потому что движок хранит вход и итог.idempotencyKeyэто ключ из прошлого урока, только для пути: одно письмо, один путь. Второй запуск с тем же номером письма не начинает новый путь, а присоединяется к старому. Если старый уже закончился,executeсразу отдаёт его итог.toLayerдаёт телу пути сервисы и регистрирует путь в движке. Тело это обычныйEffect.fn, аforпо башням обычныйfor.DurableDeferredэтоDeferredиз урока 66, который переживает перезапуск. Путь ждёт расписки адресата. Ждать он может неделю: тело пути при этом не держит файбер, движок разбудит его по расписке. Ждёт только тот, кто вызвалexecuteи хочет итог.
Расписку присылает адресат, снаружи пути:
export const confirmArrival = (letter: number, path: ReadonlyArray<string>, by: string) =>
Effect.flatMap(
DurableDeferred.tokenFromPayload(Arrived, { workflow: CarryLetter, payload: { letter, path } }),
(token) => DurableDeferred.succeed(Arrived, { token, value: by }),
);
Токен строится из того же входа, что и путь: по нему движок находит нужный запуск. succeed кладёт расписку в журнал, и путь продолжается с того места, где ждал.
Ночь между перегонами
Ночёвку на башне даёт DurableClock.sleep:
yield* DurableClock.sleep({ name: 'ночь на T-2', duration: '8 hours' });
Это не Effect.sleep. Обычный сон держит файбер восемь часов, и перезапуск обрывает его вместе с файбером. Долгий сон движок записывает в журнал как будильник: «разбудить этот путь в шесть ноль-ноль». Перезапустили машину в три, и будильник никуда не делся. Тикка права: часы, которые заводят, помнят время.
Под капотом
Где тут дыра
const carry = (letter: number, path: ReadonlyArray<string>) =>
Effect.forEach(path, (tower) =>
Effect.andThen(hops.relay(letter, tower), Effect.sleep('8 hours')),
);
На его примере письмо идёт по четырём башням, и всё хорошо. Соседний пример: в три ночи Гекс перезапускает машину. Цикл живёт в памяти воркера, и вместе с воркером пропадает знание, что две башни уже пройдены. Очередь отдаёт задание снова, цикл начинает с T-1, и первые две башни получают письмо второй раз. Если перезапуски случаются чаще, чем письмо проходит путь, оно не дойдёт никогда. В workflow тот же перезапуск ничего не повторит: два перегона записаны, а будильник ждёт в журнале.
Путь в тесте
Движок в памяти это WorkflowEngine.layerMemory. Перезапуски он не переживает, зато ведёт себя как настоящий: журнал, активности, ключи, будильники.
const tick = (seconds: number) =>
Effect.gen(function* () {
for (let second = 0; second < seconds; second++) {
for (let spin = 0; spin < 5; spin++) yield* Effect.yieldNow;
yield* TestClock.adjust('1 second');
}
});
Повторы перегонов ждут по часам, поэтому тест двигает TestClock. Двигать надо шагами и давать файберам уснуть (yieldNow): сдвиг часов разбудит только того, кто уже заснул. Сдвиг на час одним махом, пока путь ещё не дошёл до сна, ничего не разбудит.
Нестабильно. Модули
effect/workflow(Workflow,Activity,DurableClock,DurableDeferred,WorkflowEngine) в 4.1 помечены@stability unstable: имена и опции могут поменяться в минорной версии. Урок написан на 4.1.0.
Линия, этап 6
Линия получает пути писем. Новый модуль src/route.ts: перегон как активность с повторами, путь как workflow с ключом по номеру письма, расписка адресата как DurableDeferred. Тесты проверяют порядок перегонов, то, что повторный запуск ничего не шлёт заново, и то, что капризная башня получает письмо после двух отказов.
Практика
Глубже в архиве
Долгие процессы, саги и машины состояний без привязки к Effect разобраны в уроке про процессы и формальные методы раздела про бэкенд.
Дальше
К вечеру тестовые письма ходят по линии и ночуют где положено. Рыдкулл приносит из дворца бумагу с печатью.
Заказ ждёт в следующем уроке.