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