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

Расписание

middle~25 мин

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

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

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

Молоток

Вот как башни Академии повторяют сейчас, ещё со времён скорописи из урока 02:

async function sendWithRetry(line: string) {
  for (let attempt = 1; attempt <= 5; attempt++) {
    try {
      return await send(line);
    } catch {}
    await sleep(10);
  }
}

Посмотри, что тут склеено в одну функцию: что делать (отправить строку), когда повторять (через десять миллисекунд, пять раз) и что повторять (любой провал). Если Тикка скажет «повторяй реже», придётся править функцию отправки. Если другой части башни нужна та же пауза, её скопируют. А проверить, что повторы идут через десять миллисекунд, можно только подождав их.

Effect разделяет эти три вещи. Что делать, это квитанция, она у тебя уже есть. Когда повторять, это расписание, Schedule. Что повторять, решают Effect.retry и Effect.repeat, о них следующий урок.

Расписание это значение

import { Schedule } from 'effect';

export const pulse: Schedule.Schedule<number> = Schedule.spaced('1 second');

Это не цикл и не таймер. Это запись правила «после каждого раза жди секунду», как квитанция это запись заклинания. Её можно положить в константу, отдать другому, собрать из неё новое расписание. Ничего не происходит, пока расписание не отдали тому, кто повторяет.

Чтобы увидеть расписание, его нужно с чем-то запустить. Тест этапа делает так: работа пишет, в какую миллисекунду она началась, а Effect.repeat(работа, расписание) запускает её первый раз сразу и дальше по расписанию. Часы тестовые: сутки проходят за миллисекунды. Как они устроены, разберём в уроке 74, а пока просто смотри на числа.

spaced и fixed

Schedule.spaced('1 second') ждёт секунду после конца каждого раза. Пусть удар пульса длится 300 мс:

spaced 1s, удар 300 [ 0, 1300, 2600, 3900, 5200, 6500 ]

Между началами 1300: триста работы и тысяча паузы. Работа длиннее, значит удары реже.

Schedule.fixed('5 seconds') держит шаг между началами, сколько бы ни длилась работа. Это обход соседей по часам: в начале каждой пятой секунды, а не через пять секунд после прошлого обхода.

fixed 5s, обход 0 [ 0, 5000, 10000, 15000, 20000 ]
fixed 5s, обход 2000 [ 0, 7000, 12000, 17000 ]
fixed 5s, обход 7000 [ 0, 12000, 19000 ]

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

exponential и upTo

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

export const backoff = (base: Duration.Input, times: number): Schedule.Schedule<Duration.Duration> =>
  Schedule.exponential(base).pipe(Schedule.upTo({ times }));

Schedule.exponential('100 millis') ждёт сто миллисекунд, потом двести, четыреста, и так вдвое каждый раз. Второй аргумент меняет множитель: exponential('100 millis', 3) растит втрое. Само по себе оно бесконечное, поэтому сверху Schedule.upTo({ times: 3 }): не больше трёх повторов.

exponential 100, upTo 3 [ 0, 100, 300, 700 ]

Первый запуск плюс три повтора, всего четыре. У upTo есть и второй предел, по времени: upTo({ duration: '3 seconds' }) перестаёт повторять, когда с начала прошло больше трёх секунд. Оба можно поставить вместе.

Duration.Input в сигнатуре это всё, что Effect понимает как длительность: строка '100 millis', '1 second', '5 minutes', число миллисекунд или готовая Duration. Подробно о длительностях в уроке 74.

Что расписание отдаёт

Тип Schedule.Schedule<number> у пульса и Schedule.Schedule<Duration.Duration> у backoff отличаются первым параметром. Это выход расписания: что оно сообщает после каждого шага. spaced, fixed и recurs считают повторы, exponential отдаёт текущую паузу:

выход recurs(3): 3
выход exponential: 8 millis

Effect.repeat отвечает последним выходом расписания. Тип полный такой: Schedule<Output, Input, Error, Env>. Про Input поговорим ниже, Error и Env как у квитанции: расписание может упасть и может просить сервисы. У всех расписаний этого урока они never.

recurs

Schedule.recurs(3) это три повтора без пауз:

recurs 3 [ 0, 0, 0, 0 ]

Само по себе оно нужно редко, зато хорошо ограничивает другие расписания, когда их складывают. Как складывать, в следующем уроке.

jittered

Вернёмся к жалобе Тикки: все башни разом. Даже с растущей паузой башни, которые упали в одну миллисекунду, повторят тоже в одну: через 100, через 300, через 700. Магистраль встаёт, получает сто кадров в одну миллисекунду и ложится снова. Это называют стадом.

Лекарство это джиттер: каждая пауза чуть случайна.

export const politeBackoff = (base: Duration.Input, times: number): Schedule.Schedule<Duration.Duration> =>
  Schedule.jittered(backoff(base, times));

Schedule.jittered умножает каждую паузу на случайное число от 0.8 до 1.2. Два запуска одного и того же расписания:

jittered [ 0, 101, 291, 707 ]
jittered [ 0, 95, 333, 754 ]

Пауза по-прежнему растёт, но у каждой башни свои миллисекунды. Тысяча башен с таким расписанием постучат не одной волной, а размазанной по сорока процентам паузы.

Собери расписание сам и посмотри на ось: где начинаются попытки, чем spaced отличается от fixed, когда работа длинная, и как джиттер раздвигает попытки.

while

Расписание может решать, повторять ли, по тому, что видит. Schedule.while получает метаданные шага и отвечает true или false:

Schedule.spaced('1 second').pipe(Schedule.while(({ attempt }) => attempt <= 2));
Schedule.spaced('1 second').pipe(Schedule.while(({ elapsed }) => elapsed < 3000));
while attempt <= 2 [ 0, 1000, 2000 ]
while elapsed < 3000 [ 0, 1000, 2000, 3000 ]

В метаданных attempt это номер шага с единицы, elapsed миллисекунды с начала, elapsedSincePrevious с прошлого шага, output выход расписания и input, вход. Вход это то, что расписанию дают на каждом шаге: в repeat это успех работы, в retry ошибка. Поэтому у расписания есть второй параметр типа, Input: расписание, которое смотрит в ошибку провода, имеет тип Schedule<..., SendFailed>, и отдать его повторять что-то другое компилятор не даст.

Под капотом

Schedule.toStep(schedule) отдаёт эту функцию: квитанцию, которая заводит состояние расписания (сколько было шагов, когда начали) и возвращает (now, input) => .... Ответ это пара [выход, пауза] или особая ошибка «расписание кончилось». Effect.repeat крутит цикл: работа, шаг расписания, сон на паузу, снова работа. Поэтому одно и то же расписание можно запускать сколько угодно раз: состояние заводится при каждом запуске, а значение Schedule остаётся неизменным, как квитанция.

Где тут дыра

for (let attempt = 1; attempt <= 3; attempt++) {
  try {
    return await send(line);
  } catch {}
  await sleep(1000);
}

Три башни, Магистраль лежит первые полторы секунды, каждая башня пишет, когда стучала:

{
  'T-1': [ 0, 1000, 2000 ],
  'T-2': [ 0, 1000, 2000 ],
  'T-3': [ 0, 1000, 2000 ]
}

Пауза стала больше, но башни упали в одну миллисекунду и повторяют в одну. Башен у Академии не три, а сто: в 1000 Магистраль, которая только поднимается, получает сто кадров разом и снова ложится. Секунда вместо десяти миллисекунд сделала молоток медленнее, но не перестала быть молотком. А catch {} прячет, почему отправка не прошла: обрыв, который стоит повторить, и отказ «нет такой башни», который повторять бессмысленно, выглядят одинаково.

Лечится расписанием с растущей паузой и джиттером, politeBackoff, а «что повторять» решает retry в следующем уроке.

Башня, этап 36

У башни появляется модуль часов. Первым в нём будет расписание: пульс, обход соседей по часам, растущая пауза перед повтором и вежливая пауза с джиттером. Повторять по нему башня начнёт на следующем этапе.

Практика

Дальше

Тикка сворачивает свои бумаги и смотрит на твой модуль часов.

Повторы по расписанию ждут в следующем уроке.