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

Слепота к несчастливому пути

middle~35 мин

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

Ты пришёл в Академию с готовым ответом. Пять лет на TypeScript, строгие типы, тесты, ревью. Вчерашние пять дыр ты нашёл и закрыл за полчаса. Зачем тебе свитки, если ты и так пишешь хороший код?

Рыдкулл спрашивает не о том, умеешь ли ты писать повторы. Умеешь. Он спрашивает, во что превратится функция, когда ты допишешь их все. В этом уроке ты потратишь на это весь день и увидишь, что каждое требование надёжности на голом async/await стоит строк, а тип функции к вечеру скажет о ней не больше, чем утром.

Счастливый путь

Код, который мы пишем, почти всегда рассказывает одну историю: соседи на месте, сеть быстрая, кадр правильный. Это счастливый путь. Его мы держим в голове, когда пишем, его проверяем первым тестом, его видим в типе: Promise<number> означает «когда всё получится, будет число».

У каждой строки на счастливом пути есть тень. Сосед не ответил. Ответил через минуту. Ответил дважды. Бросил не то, что ждали. Рыдкулл передумал. Всё это несчастливый путь, и в коде на async/await его не видно. Он не записан ни в сигнатуре, ни в типе, ни в структуре функции. Он живёт в catch, в комментариях и в головах тех, кто однажды уже обжёгся.

Эту беду называют слепотой к несчастливому пути. С этим доводом за Effect выступал на конференции Effect Days Диллон Малрой (Dillon Mulroy), тогда инженер Vercel. Проверим довод на своей башне: допишем к ней всё, что просил Рыдкулл, по одному требованию.

Что лежит в catch

Первым делом журнал должен говорить, почему сосед не принял кадр. Пишешь catch (error) и тянешься к error.message. Строгий TypeScript не даёт: в catch у переменной тип unknown. Это часть режима strict, флаг useUnknownInCatchVariables (все строгие флаги разберём в уроке 04), и он прав. Бросить могли что угодно, это пятое место из прошлого урока: сосед может бросить и строку 'шторм'. Поэтому сначала разбор:

const messageOf = (error: unknown): string =>
  error instanceof Error ? error.message : String(error);

Это решает журнал, но не главное. По unknown нельзя понять, какая ошибка перед тобой: сосед в тумане, у соседа сломаны ставни или в нашем собственном коде опечатка. А тому, кто вызывает relayFrame, тип Promise<number> не говорит даже того, что ошибки бывают.

Таймаут

Своего таймаута у промиса нет. Первое, что приходит в голову, это гонка с таймером:

const withTimeout = <A>(work: Promise<A>, ms: number): Promise<A> =>
  Promise.race([
    work,
    new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
  ]);

Когда таймер выигрывает, withTimeout отклоняется, и ты идёшь дальше. А work продолжает работать. Promise.race перестаёт ждать проигравшего, но не останавливает его: ставни в сторону T-4 так и остались открыты, запрос к ней так и висит. Таймаут, который не останавливает работу, это не таймаут, а нетерпение.

Отмена

Остановить промис снаружи нельзя: у него нет такой кнопки. В JavaScript для этого есть AbortSignal (подробно он разобран в уроке «События, сигналы и AbortController»), и работает он по договорённости. Ты создаёшь сигнал и передаёшь его вниз руками, в каждую функцию по дороге, а каждая функция обещает его слушать. Поэтому у send меняется сигнатура: теперь она принимает третий аргумент, signal. Таймаут становится сигналом, который сработает сам через timeoutMs, и его склеивают с сигналом отмены от Рыдкулла:

const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)]);
await deps.send(tower, frame, signal);

Две строки, но они тянут за собой всё остальное. Каждый, кто вызывает relayFrame, теперь обязан передать сигнал. Каждая функция ниже send тоже. И если хоть одна из них сигнал проигнорирует, отмена до неё не дойдёт, а компилятор об этом не предупредит.

Повторы

Сосед в тумане отвечает со второго раза, значит, нужен цикл попыток. Между попытками пауза, и каждая следующая длиннее: 100 * 2 ** attempt миллисекунд. Пауза тоже обязана слушать сигнал, иначе отмена будет ждать, пока она кончится. И сразу вопрос, на который нет ответа: какие ошибки повторять? Туман развеется. Заклинившие ставни сами не починятся, и повторять их значит тратить время. Но в catch у тебя unknown, и отличить одно от другого без проверок нечем. Поэтому цикл повторяет всё подряд.

Зависимости

Для журнала со временем функции нужны часы. Можно позвать Date.now() прямо в коде, но тогда в тесте время не подменить, и функция тайком зависит от того, чего нет в её сигнатуре. Такие связи называют скрытыми зависимостями. Честный путь это положить часы в deps рядом с send и log. Тогда каждый, кто вызывает relayFrame, обязан собрать полный deps и передать его дальше. Чем больше в программе функций, тем длиннее тянутся эти провода через весь код.

Трасса

Журнал говорит relayed 1/3, но Рыдкулл хочет знать больше: сколько отвечала каждая башня и с какой попытки. Это трасса. На голом async/await её собирают руками: засечь время до, засечь после, записать строку. Настоящая трасса со вложенными шагами потребует ещё и передавать контекст через каждый вызов, как сигнал.

Что выросло к вечеру

Вот функция башни со всеми требованиями Рыдкулла.

export type Deps = {
  readonly send: (tower: string, frame: string, signal: AbortSignal) => Promise<void>;
  readonly log: (line: string) => void;
  readonly now: () => number;
};

export type Options = {
  readonly timeoutMs: number;
  readonly attempts: number;
  readonly signal: AbortSignal;
};

const messageOf = (error: unknown): string =>
  error instanceof Error ? error.message : String(error);

const sleep = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal.addEventListener(
      'abort',
      () => {
        clearTimeout(timer);
        reject(signal.reason);
      },
      { once: true },
    );
  });

const sendWithRetry = async (deps: Deps, tower: string, frame: string, options: Options) => {
  const started = deps.now();
  let last: unknown;
  for (let attempt = 1; attempt <= options.attempts; attempt += 1) {
    options.signal.throwIfAborted();
    const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)]);
    try {
      await deps.send(tower, frame, signal);
      deps.log(`span ${tower}: ok in ${deps.now() - started}ms, attempt ${attempt}`);
      return;
    } catch (error) {
      last = error;
      if (attempt < options.attempts) await sleep(100 * 2 ** attempt, options.signal);
    }
  }
  deps.log(`span ${tower}: failed in ${deps.now() - started}ms`);
  throw last;
};

export async function relayFrame(
  frame: string,
  towers: readonly string[],
  deps: Deps,
  options: Options,
): Promise<number> {
  const results = await Promise.allSettled(
    towers.map((tower) => sendWithRetry(deps, tower, frame, options)),
  );
  let sent = 0;
  for (const [index, result] of results.entries()) {
    if (result.status === 'fulfilled') sent += 1;
    else deps.log(`failed ${towers[index]}: ${messageOf(result.reason)}`);
  }
  deps.log(`relayed ${sent}/${towers.length}`);
  return sent;
}

Утром в функции было двадцать с небольшим строк. Теперь около шестидесяти, и про сам кадр из них одна: await deps.send(tower, frame, signal). Остальное про несчастливый путь. Запустим на соседях: T-2 отвечает со второй попытки, у T-3 заклинило ставни, T-4 спит. Две попытки, таймаут пятьдесят миллисекунд:

span T-2: ok in 202ms, attempt 2
span T-3: failed in 202ms
span T-4: failed in 302ms
failed T-3: ставни заклинило
failed T-4: The operation was aborted due to timeout
relayed 1/3

Журнал наконец говорит правду. Но присмотрись. T-3 честно отработала вторую попытку и двести миллисекунд паузы, хотя ставни от ожидания не починятся. Таймаут подписан не твоими словами, а словами платформы, и в типах его нет: это DOMException с именем TimeoutError, и узнал ты о нём только из журнала. А тип функции остался прежним, Promise<number>. Всё, что ты сегодня выучил о провалах башни, живёт в теле функции, и тот, кто её вызывает, не увидит ничего.

И это ещё не всё, что нужно башне. Ответ соседа приходит с провода, и его форму хорошо бы проверить. Однажды Рыдкулл попросит ограничить, сколько отправок идёт одновременно. Каждое новое требование снова войдёт в каждую функцию по дороге.

Кривая сложности

Ниже модель того, как растёт код, когда требования надёжности добавляются одно за другим. Включай повторы, таймауты, отмену, зависимости, трассировку и проверку входа схемой и смотри на две кривые: число строк на async/await и на Effect. Это модель, а не замер: числа в ней условные, и в твоём проекте они будут другими. Смотри не на числа, а на форму кривых.

На async/await каждое требование вплетается во все остальные: повтор должен знать про отмену, отмена про паузу, трасса про повтор. Строки складываются, а сверху растут строки на стыки требований: виджет считает их отдельно. У Effect каждое требование это отдельный кусок, который пристёгивается к записи заклинания и не лезет внутрь соседних.

Тот же список на Effect

Для сравнения те же требования, записанные заклинанием. Как и в прошлом уроке, это взгляд вперёд, а не задача: повторы и расписания придут в разделе EF8, сервисы в EF5.

import { Context, Data, Effect, Result, Schedule } from 'effect';

class SendFailed extends Data.TaggedError('SendFailed')<{ readonly tower: string }> {}

class Towers extends Context.Service<
  Towers,
  { readonly send: (tower: string, frame: string) => Effect.Effect<void, SendFailed> }
>()('Towers') {}

const relayFrame = Effect.fn('relayFrame')(function* (frame: string, towers: readonly string[]) {
  const { send } = yield* Towers;
  const results = yield* Effect.forEach(
    towers,
    (tower) =>
      send(tower, frame).pipe(
        Effect.timeout('1 second'),
        Effect.retry({ times: 2, schedule: Schedule.exponential('100 millis') }),
        Effect.tapError((error) => Effect.logWarning(`failed ${tower}: ${error._tag}`)),
        Effect.withSpan('send', { attributes: { tower } }),
        Effect.result,
      ),
    { concurrency: 'unbounded' },
  );
  const sent = results.filter(Result.isSuccess).length;
  yield* Effect.log(`relayed ${sent}/${towers.length}`);
  return sent;
});

Таймаут, повторы с растущей паузой, журнал неудач и шаг трассы с именем башни здесь по одной строке в цепочке после send. Отмену писать не пришлось: если таймаут сработал, Effect сам прерывает отправку, которая не успела, и прерывание доходит до всех вложенных шагов без проводов с сигналом. Зависимость от соседей записана в типе: relayFrame возвращает Effect<number, never, Towers>. Третья буква говорит, что заклинанию нужны башни, и компилятор не даст его произнести, пока ты их не дашь. В тесте вместо настоящих соседей ты дашь подставных.

Где тут дыра

Вечером к тебе подсаживается Ринцвальд, смотрит на шестьдесят строк и морщится.

Его версия на тех же Deps, что в первом уроке:

export async function relayFrame(
  frame: string,
  towers: readonly string[],
  deps: Deps,
): Promise<number> {
  let sent = 0;
  await Promise.all(
    towers.map(async (tower) => {
      try {
        await deps.send(tower, frame);
        sent += 1;
      } catch {}
    }),
  );
  deps.log(`relayed ${sent}/${towers.length}`);
  return sent;
}

На T-2 и T-6 всё хорошо: функция ждёт обе отправки, возвращает 2, в журнале relayed 2/2. Счёт даже честный: sent растёт только после удачной отправки.

Теперь добавь T-7. Её отправку Ринцвальд дописал вчера и ошибся номером ставни, так что send для неё бросает TypeError: Cannot read properties of undefined (reading 'open'). Это не туман и не сломанный сосед, это ошибка в нашем коде, которую нужно найти и исправить. Пустой catch ловит её так же охотно, как туман. В журнале relayed 2/3, и ни слова о том, что T-7 не заработает никогда, сколько её ни повторяй. Функция действительно не падает. Она врёт.

catch {} не отличает ожидаемую ошибку от дефекта. Первое надо обработать, второе исправить, а пустой catch прячет оба. В Effect у них разные места: ожидаемые ошибки записаны в E и видны в типе, а дефект идёт мимо E отдельным путём, и обработчики ожидаемых ошибок его не глотают.

Запомни эту фразу. В городе есть люди, которые любят зелёное ещё больше Ринцвальда.

Поздно вечером ты выходишь из Академии. У двери стоит Сундук: окованный латунью, на множестве коротких ножек. Ты делаешь шаг, и он делает шаг.

Никто его к тебе не посылал. Похоже, он выбрал сам.

Практика

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

Чем Promise.all, allSettled и race отличаются друг от друга и почему проигравший в гонке продолжает работать, разбирает урок «Promise, thenable и async/await». Как прокинуть контекст без проводов через каждый вызов, есть в уроке «Контекст и модели»: там AsyncLocalStorage и Effect стоят рядом.

Дальше

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

Что значит «записать» и почему от этого исчезают провода с сигналами, разберём в следующем уроке.