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

Сорок башен, четыре тысячи вопросов

middle-senior~25 мин

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

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

У этой беды есть имя: N+1. Её чаще встречают у баз данных: список из ста заказов, и к каждому отдельный запрос за покупателем. Запрос за списком писали нарочно, а остальные сто выросли из цикла.

Вопрос отдельно, ответ пачкой

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

Effect предлагает другое: каждое окошко описывает вопрос, а отвечает на вопросы один резолвер. Так работает почтальон: он не бегает на почту с каждым письмом, а ждёт, пока соберётся сумка. Вопрос это значение, как эффект:

import { Effect, Exit, Request, RequestResolver } from 'effect';

import type { TowerBookError, TowerInfo } from './towers';

class LookupTower extends Request.Class<{ readonly code: string }, TowerInfo, TowerBookError> {}

const resolver = RequestResolver.make<LookupTower>((entries) =>
  Effect.sync(() => {
    for (const entry of entries) {
      entry.completeUnsafe(Exit.succeed({ code: entry.request.code, name: 'башня', open: true }));
    }
  }),
);
  • Request.Class описывает вид вопроса: что в нём лежит (code), чем на него отвечают (TowerInfo) и чем отказывают (TowerBookError). Сам вопрос ничего не делает, это запись.
  • RequestResolver.make получает функцию от массива записей. У каждой записи есть request, сам вопрос, и completeUnsafe, которым вопрос закрывают ответом или отказом в виде Exit. Здесь резолвер отвечает выдумкой, чтобы было видно форму; настоящий пойдёт в книгу ниже.
  • Резолвер обязан закрыть каждую запись. Забытая запись это окошко, которое ждёт ответа вечно.

Задать вопрос это Effect.request:

const lookup = (code: string) => Effect.request(new LookupTower({ code }), resolver);

const noon = Effect.forEach(['T-2', 'T-3', 'T-2'], lookup, { concurrency: 'unbounded' });

Три окошка спрашивают про три башни разом. Ни одно не знает про остальные, у каждого свой lookup. А резолвер получает одну пачку из трёх записей.

Когда вопросы становятся пачкой

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

forEach с concurrency: 'unbounded' по T-2, T-3, T-2   → одна пачка: T-2, T-3, T-2
yield* lookup('T-2'); yield* lookup('T-3')         → две пачки: T-2, потом T-3

Второе не ошибка. Если второй вопрос задают после ответа на первый, значит, он от первого зависит, и ждать за ним нечего. Окошки Почтамта в полдень работают разом: сотня писем, сотня файберов, один такт.

Посмотри ещё раз на первую пачку: T-2 в ней дважды. Резолвер получает все записи, и повторы тоже, каждую нужно закрыть. А вот в провод повторы пускать незачем: коды собирают без повторов, new Set, и книгу спрашивают про каждую башню один раз. Это работа резолвера, сама пачка повторы не склеивает.

Если такт слишком короткий и вопросы не успевают встретиться, у резолвера есть RequestResolver.setDelay: он ждёт немного, прежде чем идти в книгу, и копит пачку. Плата за это задержка первого вопроса. Почтамту хватает одного такта.

Резолвер, который ходит в книгу

У книги Магистрали есть адрес, который отвечает про много башен разом: GET /towers?codes=T-2,T-3. Ответ это список тех башен, которые книга знает. Резолвер Почтамта:

const resolverFor = (client: HttpClient.HttpClient) =>
  RequestResolver.make<LookupTower>((entries) => {
    const codes = [...new Set(entries.map((entry) => entry.request.code))];
    return client.get('/towers', { urlParams: { codes: codes.join(',') } }).pipe(
      Effect.flatMap(HttpClientResponse.schemaBodyJson(Towers)),
      Effect.match({
        onFailure: (error) => {
          for (const entry of entries)
            entry.completeUnsafe(Exit.fail(new TowerBookError({ code: entry.request.code, reason: reasonOf(error) })));
        },
        onSuccess: (towers) => {
          for (const entry of entries) {
            const tower = towers.find((found) => found.code === entry.request.code);
            entry.completeUnsafe(
              tower
                ? Exit.succeed(tower)
                : Exit.fail(new TowerBookError({ code: entry.request.code, reason: 'нет такой башни' })),
            );
          }
        },
      }),
    );
  });

Три исхода, и каждый закрывает все записи.

  • Книга ответила, башня в списке: запись получает башню.
  • Книга ответила, башни в списке нет: нет такой башни. Остальные башни той же пачки при этом отвечены, беда одной не трогает соседей.
  • Провод подвёл: беду получает каждая запись пачки, с той же причиной словами, что в уроке 90. Окошко, чья башня шла в этой пачке, узнает, что книга не ответила, а не повиснет.

Effect.match здесь нужен, чтобы эффект резолвера сам никогда не падал: все беды уже розданы по записям.

Не спрашивать то, что уже знаешь

Пачка решает полдень: сто вопросов про T-2 становятся одним. Но в час дня письма на T-2 придут снова, и книгу спросят снова, хотя за час башня не переехала. Ответ стоит запомнить.

Кэш в Effect это Cache: по ключу он зовёт функцию поиска один раз и держит ответ, пока не выйдет срок. Параллельные вопросы про один ключ, на который ответа ещё нет, ждут одного и того же поиска.

const cache = yield* Cache.makeWith(
  (code: string) => Effect.request(new LookupTower({ code }), resolver),
  { capacity: 1024, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero) },
);

const lookup = (code: string) => Cache.get(cache, code);
  • Функция поиска это вопрос к резолверу из прошлого раздела. Кэш стоит перед пачкой: что знаем, не спрашиваем, чего не знаем, спрашиваем пачкой.
  • capacity это предел: больше 1024 башен кэш держать не будет, старые уйдут.
  • timeToLive здесь функция от исхода. Ответ живёт минуту. Беда живёт ноль: её не запоминают, и следующее письмо спросит книгу снова.

Почему не держать беду? Потому что беда книги обычно временная. Книга не ответила в 12:00:00, а в 12:00:05 уже отвечает. Запомнить молчание на минуту значит минуту говорить горожанам «книга молчит», когда она давно говорит. Об этом «Где тут дыра» ниже.

Кэш на один запрос

Бывает и короткая память. Внутри одного запроса к Почтамту книгу могут спросить про одну башню дважды: обработчик сначала проверяет адрес, а потом смотрит, открыта ли башня. Минутный кэш тут не нужен, нужно, чтобы в пределах одного запроса вопрос задали один раз. Это Effect.cached: он превращает эффект в эффект, который выполнится при первом yield*, а дальше отдаёт запомненный ответ.

export const checkTwice = (book: TowerBook['Service'], to: string) =>
  Effect.gen(function* () {
    const tower = yield* Effect.cached(book.lookup(to));
    const atWindow = yield* tower;
    const atDesk = yield* tower;
    return atWindow.open && atDesk.open;
  });

Книгу спросят один раз. Память живёт, пока живёт tower, то есть до конца этого запроса; следующий запрос начнёт с чистого листа. Это не замена Cache, а его младший брат на одну встречу.

Под капотом

Где тут дыра

const cache = yield* Cache.make({
  capacity: 1024,
  timeToLive: '1 minute',
  lookup: (code: string) => Effect.request(new LookupTower({ code }), resolver),
});

На его примере всё работает: книга отвечает, ответы живут минуту, запросов меньше в сто раз. Теперь соседний случай. В 12:00:00 книга на секунду замолчала. Первое письмо на T-2 получило книга не отвечает, и Cache.make честно запомнил ответ. Он запоминает исход целиком, Exit, вместе с бедой. Следующие шестьдесят секунд каждое письмо на T-2 получает ту же беду из кэша, а книга давно ожила и ни одного вопроса не получила. Минута молчания Почтамта из-за секунды молчания книги. Cache.makeWith с timeToLive по исходу держит ответы и сразу забывает беды.

Почтамт, этап 11

Почтамт перестаёт задавать книге один и тот же вопрос сто раз. Новый модуль src/batch.ts: книга башен, которая собирает вопросы одного такта в один запрос, отдаёт каждому его башню или беду и помнит ответы минуту. Тесты считают запросы к книге и двигают часы: пачка это один запрос, повторы схлопнуты, беда не запоминается.

Практика

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

Кэши без Effect, со сроками, вытеснением и вечной задачей «когда сбросить», разобраны в уроке про кэширование раздела про бэкенд.

Дальше

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

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