Раздел 25 · Effect-TS

Batching: Request, RequestResolver, dataloader, кеш с TTL

senior~120 мин

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

Batching: Request, RequestResolver, dataloader, кеш с TTL

Сцена · сто пингов это один запрос

В Pulse у тебя список из 50 URL, и для каждого нужно сначала резолвить DNS: проверить, что хост вообще существует, заодно прогреть кеш. На обычном Promise.all это 50 параллельных DNS-запросов: пятьдесят пакетов в сеть, пятьдесят раз туда и обратно к резолверу, пятьдесят аллокаций под промежуточные результаты.

Из пятидесяти URL уникальных хостов наберётся штук пятнадцать: api.example.com живёт рядом с www.example.com, cdn.example.com, static.example.com. Тридцать пять запросов из пятидесяти повторяют те же имена. Половина DNS-серверов ещё и принимает несколько вопросов в одном пакете, ты этим не пользуешься.

То же самое с whois: проверить, не истёк ли домен. Ходить за whois при каждой проверке это самоубийство, а кешировать вручную через Map<host, { value, expiresAt }> это ещё один сорт boilerplate, который придётся писать в каждом сервисе.

В этом уроке разбираем встроенную в Effect DataLoader-машинерию: Request для типизированной заявки, RequestResolver для пакетной обработки, Effect.request как точку вызова, и Cache на TTL для долгоживущих результатов. К концу урока 50 параллельных dnsLookup идут одним батч-запросом, повторяющиеся имена дедуплицируются автоматически, а whois-кеш живёт 24 часа без единой ручной строки if (Date.now() - entry.savedAt > ttl).

Карта урока · что заберёшь домой

Семь разделов:

  1. Раздел 1, N+1 в живой природе: где он берётся в Pulse и почему Promise.all его не лечит.
  2. Раздел 2, Request<A, E>, типизированная заявка с автоматическим hash и equals для дедупликации.
  3. Раздел 3, RequestResolver: fromEffect для одиночек, make и batchN для пакетов.
  4. Раздел 4, Effect.request, точка вызова. Окно сборки, setDelay, withCache.
  5. Раздел 5, под капотом: preflight, batch boundary, параллелизм. Сравнение с GraphQL DataLoader.
  6. Раздел 6, кеш: Effect.cached, cachedWithTTL, Cache.make. Когда какой.
  7. Раздел 7, Pulse: DnsResolver батчит по 8, Whois через Cache.make с TTL 24 часа, всё в MainLive.

К концу урока ты пишешь RequestResolver.make без подсматривания, понимаешь, чем Effect.request отличается от Effect.gen-обёртки над fetch, и за 30 секунд выбираешь между Effect.cached, cachedWithTTL и Cache.make под конкретную задачу.

Раздел 1 · N+1 в живой природе

Сценарий и метрика

Возьмём текущий Pulse: на тике probeAll берётся список из 50 таргетов из конфига, и для каждого запускается probeOne. Внутри probeOne сейчас один HTTP-запрос плюс публикация события (см. 01 · Intro). Добавим один шаг: перед HTTP-запросом проверить, что DNS вообще резолвится. Если хост неживой, не тратим время на TCP-handshake.

import { Effect } from 'effect';

const probeAll = (targets: ReadonlyArray<Target>) =>
  Effect.forEach(
    targets,
    (target) =>
      Effect.gen(function* () {
        const ip = yield* dnsLookup(target.host);
        if (ip === null) return { _tag: 'Skipped', target } as const;
        return yield* probeOne({ ...target, ip });
      }),
    { concurrency: 'unbounded' },
  );

50 таргетов плюс по одному dnsLookup на каждого это 50 пакетов в сеть. Метрика: на UDP latency 30мс это ~30мс суммарно (параллельно), на TCP к удалённому DNS, который не любит UDP, это уже три фазы рукопожатия по ~100мс. Числа зависят от инфраструктуры, важна не точность, а форма: одна и та же операция повторяется N раз, и каждое повторение платит свою цену.

Что не помогает

Первый рефлекс это дедупликация на стороне Pulse: пройти targets, собрать Set<host>, сделать Promise.all по этому сету. Так можно, но цена этого решения: руками держать соответствие host-> targets, руками раздавать результат обратно по таргетам, руками делать инвалидацию, если в следующем тике пришёл новый набор. И всё это для одной операции (DNS), а в Pulse их три (DNS, whois, geo-ip), и в реальном проекте обычно десять.

Второй рефлекс это локальный Map в probeAll: завести Map<host, IP>, по мере резолва класть туда. Это работает, но решение прибито к одной функции, и не переиспользуется. Пять таких функций это пять одинаковых паттернов с одинаковыми багами.

Третий рефлекс это глобальный мемоизатор поверх dnsLookup. Появляется глобальный Map. Нужно как-то инвалидировать. Нужно как-то сбрасывать в тестах. И всё ещё нет ответа на главный вопрос: даже после дедупликации у тебя 30 запросов вместо 50, и они идут по одному.

Чего хочется

Хочется сказать “вот функция dnsLookup(host): Effect<IP, DnsError>, вызывай её сколько угодно раз, в любой точке программы, даже параллельно. На каждом тике рантайм соберёт все вызовы в один пакет, отправит в dnsBatchResolver, и раздаст ответы по вызывающим. Одинаковые host в одном тике дедуплицирует автоматически. Если включишь кеш, повторные вызовы того же host в течение TTL вернут запомненный ответ”.

Это и есть паттерн Effect.request. Переходим к строительным блокам.

Что взять с собой

  • N+1 это не “ещё одна оптимизация”, это форма, которая всплывает каждый раз, когда Promise.all или Effect.forEach запускают одну и ту же операцию N раз.
  • Ручная дедупликация через Set плюс ручная раздача результата плюс ручной кеш это три отдельных места, где обычно появляются баги.
  • Идея решения: типизированная заявка плюс резолвер, который понимает пакет; точка вызова, как у обычного Effect, но рантайм собирает их вместе.

Раздел 2 · Request<A, E>, типизированная заявка

Чем Request отличается от Effect

Effect<A, E, R> это что и как сделать. Request<A, E, R> это что хочется получить, без указания как. Request сам по себе ничего не запускает, у него нет своей семантики выполнения. Семантику ему придаёт RequestResolver. Эта развязка принципиальна: одну и ту же Request<User, GetUserError> можно резолвить через HTTP в проде и через стаб в тестах, не меняя ни строки кода вызывающих.

Три параметра тут те же, что у Effect: успех, ошибка, требуемые сервисы. Третий почти всегда never и его опускают, но он есть, и именно через него резолвер получает доступ к сервисам (об этом в конце раздела 3).

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

class DnsError extends Data.TaggedError('DnsError')<{
  readonly host: string;
  readonly cause: unknown;
}> {}

interface DnsLookup extends Request.Request<string, DnsError> {
  readonly _tag: 'DnsLookup';
  readonly host: string;
}

const DnsLookup = Request.tagged<DnsLookup>('DnsLookup');

DnsError тут собран через Data.TaggedError из 03 · Errors, это твоя стандартная типизированная ошибка резолвера. Request.Request<A, E> это интерфейс, где A это успех, а E это ошибка. Request.tagged делает фабрику: DnsLookup({ host: 'a.com' }) вернёт значение этого интерфейса с правильным _tag и автоматически рассчитанным hash/equals.

Hash и equals для дедупликации

Вот ради чего пляска с Request вместо обычного Effect-аргумента: Effect-рантайм видит две заявки DnsLookup({ host: 'api.com' }) и DnsLookup({ host: 'api.com' }) как одну, потому что у Request.tagged под капотом Data.struct-семантика равенства по содержимому. Никакого JSON.stringify, никакого ручного канонического представления: рантайм считает hash от значения полей и сравнивает по equals.

Проверить можно прямо на консоли:

import { Equal } from 'effect';

const a = DnsLookup({ host: 'api.com' });
const b = DnsLookup({ host: 'api.com' });
const c = DnsLookup({ host: 'other.com' });

Equal.equals(a, b); // true
Equal.equals(a, c); // false

a и b это разные значения, но рантайм уверен, что они эквивалентны, и схлопнет их в одно при батчинге. Если бы ты руками писал dnsLookup(host: string): Effect<...>, аналогичной проверки в нём не было бы: рантайм видит две независимых вызова Effect-а, и нет никакого способа без дополнительной обвязки понять, что они “одинаковые”.

Request это просто описание

Подчеркнём ещё раз: DnsLookup({ host: 'api.com' }) ничего не делает. Это описание заявки, как HTTP-запрос без отправки. Чтобы из него получился настоящий эффект, нужен Effect.request(req, resolver). Это та же дисциплина, что и у Effect-а самого: значение Effect, лежащее в переменной, ещё не выполнялось, выполнение начинается в runPromise/runFork/yield*.

Ради чего это: ты можешь свободно создать Request, передать его в другую функцию, отложить, скомбинировать с другим. Никаких побочных эффектов до явной точки запуска. Та же дисциплина значений, что и у Effect (см. 01 · Intro).

Форма через класс · Request.TaggedClass

Request.tagged требует сначала объявить интерфейс, потом фабрику. Есть форма покороче, через класс, и дальше в Pulse мы будем пользоваться именно ей:

import { Request } from 'effect';

export class ResolveDns extends Request.TaggedClass('ResolveDns')<
  { readonly host: string }, // поля запроса
  DnsRecord,                 // успех
  DnsError                   // ошибка
> {}

const req = new ResolveDns({ host: 'api.com' });

Порядок параметров тут запомни отдельно: сначала поля, потом успех, потом ошибка. Раньше он был другим (успех и ошибка шли первыми, поля третьими), и старые примеры в интернете скомпилируются с бессмысленными типами, а не упадут с понятной ошибкой. Если видишь Request.TaggedClass('X')<UserData, MyError, { id: string }>, это код под старую версию.

Hash и equals у класса те же автоматические: два new ResolveDns({ host: 'a' }) рантайм считает одинаковыми.

Что взять с собой

  • Request<A, E, R> это типизированное описание заявки, без собственной семантики выполнения.
  • Request.tagged<R>('Tag') даёт фабрику с автоматическим hash и equals по полям, Request.TaggedClass('Tag')<Fields, Success, Error> это то же самое в форме класса.
  • Две заявки с одинаковыми полями рантайм видит как одну и схлопывает их в один вызов резолвера.
  • Сам по себе Request ничего не запускает, выполнение появляется через Effect.request.

Раздел 3 · RequestResolver, обработчик пакета

Что такое RequestResolver<A>

RequestResolver это функция, которая принимает пакет заявок одного типа и обрабатывает его. Тип у него один параметр:

import type { RequestResolver } from 'effect';

type Resolver<A extends Request.Request<unknown, unknown>> =
  RequestResolver.RequestResolver<A>;

A это конкретный тип заявки. Окружения на резолвере нет: если для исполнения нужны сервисы, они объявляются третьим параметром самой Request, и резолвер получает их вместе с заявкой (об этом ниже).

Резолвер не возвращает результаты как Effect<Array<Result>>. Он завершает каждую заявку индивидуально, и рантайм сам раздаёт результаты ждущим Effect.request.

Конверт Request.Entry

Одна деталь определяет всю форму кода резолвера: на вход приходят не сами заявки, а конверты Request.Entry<A>. Конверт это заявка плюс канал для ответа:

entry.request        // сама заявка: entry.request.host
entry.context        // сервисы, которые заявка притащила с собой
entry.completeUnsafe // низкоуровневый способ закрыть заявку через Exit

Правило чтения кода резолвера простое: где раньше был req, теперь entry.request для чтения полей и entry для ответа.

Закрыть конверт можно тремя способами:

  • Request.succeed(entry, value), успех;
  • Request.fail(entry, error), ошибка;
  • Request.completeEffect(entry, eff), результат посчитает эффект.

Незакрытый конверт это зависший вызывающий: молчаливый return из резолвера оставит заявки ждать навсегда.

fromEffect, одиночный обработчик

Самая простая форма, для случаев, когда батч физически не сделать:

import { Effect, RequestResolver } from 'effect';

const SimpleDnsResolver = RequestResolver.fromEffect((entry: Request.Entry<DnsLookup>) =>
  Effect.tryPromise({
    try: () => dns.resolve4(entry.request.host).then((ips) => ips[0] ?? ''),
    catch: (cause) => new DnsError({ host: entry.request.host, cause }),
  }),
);
// SimpleDnsResolver: RequestResolver<DnsLookup>

Этот резолвер не батчит. На пакет из 50 заявок рантайм всё равно вызовет его 50 раз (внутренне в одной фазе, но реально это 50 параллельных промисов). Зачем такое нужно: дедупликация по hash и equals из раздела 2 всё ещё работает, и кеш через RequestResolver.withCache тоже. Это даёт половину выигрыша даром.

Обрати внимание, что fromEffect это единственная форма, где ты возвращаешь значение вместо того, чтобы закрывать конверт руками. Он берёт это на себя, потому что заявка ровно одна.

make, ядро DataLoader

Вот ради чего весь раздел:

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

const DnsResolver = RequestResolver.make<DnsLookup>((entries) =>
  Effect.gen(function* () {
    const hosts = entries.map((entry) => entry.request.host);
    // одна сетевая операция на весь пакет
    const outcome = yield* batchResolveDns(hosts).pipe(Effect.result);

    if (Result.isFailure(outcome)) {
      // весь пакет не получился, размечаем каждую заявку как fail
      yield* Effect.forEach(entries, (entry) => Request.fail(entry, outcome.failure), {
        discard: true,
      });
      return;
    }

    // пакет получился, размечаем каждую заявку индивидуально
    yield* Effect.forEach(
      entries,
      (entry) => {
        const host = entry.request.host;
        const ip = outcome.success.get(host);
        return ip === undefined
          ? Request.fail(entry, new DnsError({ host, cause: 'not found' }))
          : Request.succeed(entry, ip);
      },
      { discard: true },
    );
  }),
);
// DnsResolver: RequestResolver<DnsLookup>

Что важно понять:

  • На вход приходит уже дедуплицированный пакет конвертов: рантайм схлопнул host: 'a.com' плюс host: 'a.com' в одну заявку до вызова резолвера.
  • Резолвер сам решает, как разрезать ответ: словарь Map<host, ip> это удобный паттерн, в реальной API чаще приходит массив, тогда надо завести индекс.
  • Effect.result заворачивает исход в Result, который в Effect играет роль “либо ошибка, либо успех”. Проверяем через Result.isFailure, читаем через .failure и .success. Это тот же тип, что раньше назывался Either с полями left и right; переименование сквозное по всей библиотеке, включая Effect.either, который стал Effect.result (03 · Errors).
  • discard: true в forEach это “мне не нужны возвращаемые значения, экономь память на сборке массива”.

batchN, ограничение размера пакета

Внешний DNS-резолвер обычно не любит пакеты по 5000. Эмпирическое ограничение Pulse это 8 имён в одном UDP-пакете. Эффект даёт декоратор:

import { RequestResolver } from 'effect';

const BoundedDnsResolver = RequestResolver.batchN(DnsResolver, 8);

batchN(resolver, n) оборачивает существующий резолвер так, чтобы рантайм не передавал ему пакеты больше, чем n. На 50 уникальных заявках получится 7 пакетов (6 по 8 плюс один по 2), и они уйдут к резолверу параллельно, потому что собрать весь пакет это вопрос O(пакета), не O(всех заявок). В Pulse после дедупликации уникальных обычно меньше: 15 хостов это 2 пакета (8 + 7).

makeGrouped, разные пакеты по признаку

Соседний инструмент, который часто нужен вместе с batchN. Если заявки идут к разным шардам, регионам или таблицам, смешивать их в одном пакете нельзя, и makeGrouped разложит их по ключу за тебя:

import { RequestResolver } from 'effect';

const ShardedResolver = RequestResolver.makeGrouped({
  key: (entry) => shardOf(entry.request.host),
  resolver: (entries, shard) => queryShard(shard, entries),
});

Рантайм соберёт отдельный пакет на каждое значение ключа и вызовет resolver для каждого, передав вторым аргументом сам ключ. Без этого пришлось бы группировать вручную внутри make, а потом ещё и разруливать частичные отказы по группам.

Резолвер с зависимостями

Резолверу почти всегда что-то нужно снаружи: HTTP-клиент, пул соединений, DNS-клиент. У типа RequestResolver<A> окружения нет, и это осознанно: резолвер должен быть самодостаточным значением, которое можно положить в модуль-константу.

Самый прямой способ это фабрика. Зависимость приходит обычным аргументом функции, и подмена в тестах становится тривиальной:

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

export const makeDnsResolver = (
  lookup: (hosts: ReadonlyArray<string>) => Promise<ReadonlyArray<DnsRecord>>,
): RequestResolver.RequestResolver<ResolveDns> =>
  RequestResolver.make((entries) =>
    Effect.gen(function* () {
      const hosts = entries.map((entry) => entry.request.host);
      const outcome = yield* Effect.tryPromise({
        try: () => lookup(hosts),
        catch: (cause) => new DnsError({ host: hosts.join(','), cause }),
      }).pipe(Effect.result);

      if (Result.isFailure(outcome)) {
        yield* Effect.forEach(entries, (entry) => Request.fail(entry, outcome.failure), {
          discard: true,
        });
        return;
      }

      yield* Effect.forEach(
        entries,
        (entry) => {
          const host = entry.request.host;
          const match = outcome.success.find((r) => r.host === host);
          return match === undefined
            ? Request.fail(entry, new DnsError({ host, cause: 'lookup miss' }))
            : Request.succeed(entry, match);
        },
        { discard: true },
      );
    }),
  );

В MainLive ты собираешь резолвер один раз поверх настоящего DNS-клиента, в тестах поверх стаба со счётчиком вызовов (04 · Services и Layer).

Есть и второй путь, для случаев, когда зависимость обязана резолвиться в момент вызова, а не в момент сборки: объяви её третьим параметром самой Request (Request.Request<string, DnsError, DnsClient>). Тогда заявка утащит сервис с собой в конверт, и резолвер достанет его из entry.context. Требования при этом окажутся в типе Effect.request, а не в типе резолвера.

Что взять с собой

  • RequestResolver.fromEffect, одиночка: ничего не батчит, но даёт дедупликацию и кеширование даром.
  • RequestResolver.make, ядро: получает уже дедуплицированный пакет конвертов и сам распределяет результаты через Request.succeed и Request.fail.
  • RequestResolver.batchN(resolver, n), ограничитель размера пакета; makeGrouped({ key, resolver }), разбивка на пакеты по признаку.
  • Сервисы объявляются на Request третьим параметром и приезжают в резолвер через entry.context.

Раздел 4 · Effect.request, точка вызова

Базовое использование

import { Effect } from 'effect';

const dnsLookup = (host: string) =>
  Effect.request(DnsLookup({ host }), DnsResolver);
// dnsLookup: (host: string) => Effect<string, DnsError>

Effect.request соединяет заявку с резолвером и возвращает обычный Effect, который ведёт себя ровно так, как ожидает интерфейс Request-а: A в успехе, E в провале, R это окружение резолвера. Снаружи код выглядит как обычная асинхронная функция:

const program = Effect.gen(function* () {
  const ip = yield* dnsLookup('api.com');
  return ip;
});

И только Effect-рантайм знает, что внутри лежит Request, и его можно сгруппировать с другими.

Батчинг включён по умолчанию

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

import { Effect } from 'effect';

const probeAll = (targets: ReadonlyArray<Target>) =>
  Effect.forEach(
    targets,
    (target) =>
      Effect.gen(function* () {
        const ip = yield* dnsLookup(target.host);
        return yield* probeOne({ ...target, ip });
      }),
    { concurrency: 'unbounded' },
  );

Все 50 dnsLookup встанут в одну фазу сборки, рантайм увидит их как пакет одного типа DnsLookup, дедуплицирует одинаковые host, разрежет на пакеты по batchN(8), отправит в резолвер.

Если ты читаешь материалы постарше и видишь { concurrency: 'unbounded', batching: true } или вызовы Effect.withRequestBatching, знай: этой опции больше нет, и она не нужна. Батчинг переехал из прикладного кода в резолвер, и настраивается там.

Окно сборки и setDelay

Что именно решает, когда пакет “закрывается”? У каждого резолвера есть поле delay, эффект-задержка. По умолчанию это Effect.yieldNow: подождать один тик планировщика, собрать всё, что успело прийти, и поехать. Ровно та же граница, что у классического DataLoader.

Иногда тика мало. Если заявки приходят из независимых источников (веб-запросы от разных клиентов, например), их стоит подкопить подольше:

import { RequestResolver } from 'effect';

const WindowedDnsResolver = RequestResolver.setDelay(DnsResolver, '20 millis');

Теперь резолвер ждёт 20 миллисекунд, собирая заявки, и только потом выполняет пакет. Плата очевидна: каждый вызов становится на 20 мс медленнее. Это обмен латентности на размер пакета, и правильная величина всегда зависит от того, что дороже в твоей задаче.

withCache, кеш по hash и equals

Кеш заявок это отдельный механизм, ортогональный батчингу. Он не про TTL, он про “один и тот же Request исполняется один раз”. Живёт кеш на резолвере:

import { Effect, RequestResolver } from 'effect';

const program = Effect.gen(function* () {
  const cachedResolver = yield* RequestResolver.withCache(DnsResolver, {
    capacity: 512,
  });

  // все Effect.request через cachedResolver ходят через кеш
  yield* probeAll(targets, cachedResolver);
});

Под капотом это Map от значения заявки к результату, и hash с equals у Request.tagged гарантируют, что одинаковые заявки попадут в один ключ. Вытеснение по умолчанию LRU, ограничено capacity. Две ловушки: записи не истекают по времени, а неудачи кешируются наравне с успехами.

Родственные инструменты в том же модуле: RequestResolver.asCache отдаёт полноценный Cache (с TTL, refresh и invalidate), а RequestResolver.persisted кладёт результаты во внешнее хранилище.

Включать кеш или нет, зависит от семантики. Для dnsLookup это ровно то, что нужно. Для sendEmail это смертельно: ты не хочешь, чтобы повторный вызов “тихо” вернул старый успех, потому что он не послал письмо. Эмпирика: чтения кешируешь, записи нет.

Если нужен кеш с TTL (а это почти всегда нужно), бери RequestResolver.asCache или обычный Cache.make. Об этом раздел 6.

Дедупликация без всякого кеша

Тонкий момент: дедупликация работает и без withCache. Если в одном тике у тебя три dnsLookup('api.com') параллельно через Effect.all, рантайм сольёт их в одну заявку, даже если резолвер fromEffect не батчит. Просто из трёх параллельных эффектов один реально пойдёт к резолверу, два получат тот же результат через внутреннюю синхронизацию.

Разница между двумя механизмами во времени жизни. Дедупликация работает внутри одного окна сборки и ничего не помнит после него. Кеш помнит между окнами, пока не вытеснит по capacity.

Что взять с собой

  • Effect.request(req, resolver) это точка вызова, наружу выглядит как обычный Effect.
  • Батчинг включён всегда, отдельного флага на Effect.forEach нет.
  • Ширина окна сборки живёт на резолвере: по умолчанию один тик планировщика, RequestResolver.setDelay растягивает его.
  • RequestResolver.withCache(resolver, { capacity }) кеширует результаты по значению заявки (без TTL).
  • Дедупликация по hash и equals работает и без кеша, на параллельных одинаковых заявках.

Раздел 5 · Под капотом

Preflight, batch boundary, parallelism

Когда ты пишешь Effect.forEach(targets, ..., { concurrency: 'unbounded' }), рантайм делает примерно следующее:

  1. Preflight. Запускает все 50 Effect.gen-блоков параллельно. Каждый из них доходит до yield* dnsLookup(host), который под капотом это Effect.request. На этом моменте файбер не блокируется: он регистрирует свою заявку в очереди ожидающих заявок и ждёт, пока пакет соберётся.
  2. Batch boundary. Открывается окно сборки, заданное полем delay резолвера (по умолчанию один тик планировщика). Когда оно закрылось, рантайм видит набор: [DnsLookup({ host: 'a.com' }), DnsLookup({ host: 'a.com' }), DnsLookup({ host: 'b.com' }), ...]. Это и есть граница пакета.
  3. Группировка. Заявки одного резолвера собираются в один массив конвертов. Дедупликация: по hash и equals одинаковые свёрнуты в одну. Если есть batchN, массив режется на куски заданного размера, а если makeGrouped, то на группы по ключу.
  4. Исполнение. Каждый кусок идёт в резолвер как один вызов. Если кусков несколько, они идут параллельно (рантайм fork-ает их как отдельные файберы).
  5. Раздача. Резолвер через Request.succeed и Request.fail пишет результат в каждый конверт, рантайм будит ждущие файберы.

Эта последовательность это и есть встроенный DataLoader-петля, только не привязанный к process.nextTick, а встроенный в дисциплину файберов.

Что значит “batch boundary”

Граница пакета это момент, когда рантайм решает “ждать новых заявок больше нет смысла, поехали обрабатывать что есть”. Решает это сам резолвер, своим полем delay: пока эффект-задержка не завершилась, окно открыто и новые заявки попадают в тот же пакет. С дефолтным Effect.yieldNow окно это один тик планировщика, и для нашего forEach с 50 элементами этого хватает: все 50 файберов успевают добежать до dnsLookup и зарегистрировать заявки в пределах одного тика.

Если в Effect.gen-блоке после dnsLookup идёт ещё whoisLookup, рантайм соберёт сначала пакет DNS-заявок, выполнит, разбудит файберы, они побегут дальше и упрутся в whois. Тогда соберётся пакет whois-заявок. Получается две границы пакета, последовательно.

Это поведение по умолчанию. В большинстве случаев оно правильное: ты не хочешь начать whois для тех таргетов, у которых DNS уже провалился.

Дедупликация в деталях

Структура для дедупликации это что-то вроде HashMap<RequestHash, Set<FiberId>>. Когда заявка приходит, рантайм считает её hash, ищет в карте: если есть, добавляет файбер в Set ждущих; если нет, регистрирует новую запись и записывает файбер как первого ждущего. После исполнения резолвера рантайм проходит по Set и каждому файберу выдаёт один и тот же результат.

Главное следствие: резолвер видит уникальные заявки. Если из 50 у тебя 30 уникальных, резолвер получит массив из 30 конвертов, не 50.

Параллелизм пакетов и резолверов

Если в одном forEach ты делаешь два разных Effect.request (DNS и whois), они идут к разным резолверам, и пакеты собираются независимо: один пакет в DnsResolverLive, другой в WhoisResolverLive. Они выполняются параллельно, не блокируя друг друга.

Если у тебя один резолвер, но batchN(8) режет его на куски, все куски идут параллельно. Это снова про файберы: каждый кусок это отдельный fork, рантайм их исполняет на одной event loop.

Сравнение с GraphQL DataLoader

Facebook DataLoader решает ту же задачу, и форма у решений похожая. Различия:

  • Граница тика. У DataLoader это process.nextTick (или микротаск). У Effect это окно сборки резолвера плюс синхронизация файберов, и оно работает в любом рантайме (Node, Bun, Deno, браузер), а ширину окна ты задаёшь сам через setDelay.
  • Дедупликация. У DataLoader через переданный cacheKeyFn (по умолчанию по ===). У Effect через hash и equals у Request.tagged, без отдельной функции.
  • Кеш с TTL. У DataLoader его нет, кеш сбрасывается между запросами. У Effect есть отдельный Cache.make с TTL и capacity.
  • Композиция с другими примитивами. У DataLoader интеграция с GraphQL нативная, с остальным руками. У Effect Request/Resolver это часть системы эффектов: timeout, retry, race, interrupt работают поверх без дополнительной обвязки.

Если знаешь DataLoader, читай Effect-овский batching как “DataLoader плюс типобезопасность плюс интеграция с остальным в Effect”.

Что взять с собой

  • Жизненный цикл: preflight (запуск всех файберов параллельно), boundary (окно сборки закрылось), группировка, исполнение, раздача.
  • Граница пакета живёт на резолвере, в поле delay. По умолчанию это один тик планировщика, ждать ничего не нужно.
  • Дедупликация делается до вызова резолвера: резолвер видит уникальные заявки.
  • Разные резолверы и разные пакеты идут параллельно.
  • Аналог Facebook DataLoader, но встроенный в Effect и без привязки к event loop.

Раздел 6 · Кеш: Effect.cached, cachedWithTTL, Cache.make

Кеш в Effect это не один инструмент, а семейство, и выбор между ними это вопрос “что у меня за вход и какой нужен жизненный цикл”.

Effect.cached, “посчитай один раз”

Самая простая форма. Берёт Effect<A, E, R>, возвращает Effect<Effect<A, E>, never, R>: внешний эффект собирает кеш-объект, внутренний это уже закешированная версия.

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const expensive = yield* Effect.cached(loadConfigFromDisk);
  // expensive: Effect<PulseConfig, ConfigError>

  const a = yield* expensive;
  const b = yield* expensive;
  // loadConfigFromDisk был вызван один раз, b пришёл из кеша
});

Сценарий: в Pulse есть loadConfig, и в MainLive его дёргают три сервиса, и хочется быть уверенным, что чтение с диска одно. До Effect.cached приходилось делать это через Deferred (см. 07 · Координация). С Effect.cached это одна строка.

Без TTL, без capacity, без ключа: один вход в Effect.cached это один кеш на одно значение.

Effect.cachedWithTTL, автоинвалидация

Та же форма, плюс время жизни:

import { Effect } from 'effect';

const program = Effect.gen(function* () {
  const liveDns = yield* Effect.cachedWithTTL(
    dnsLookup('api.example.com'),
    '5 minutes',
  );

  const a = yield* liveDns; // реально резолвит
  yield* Effect.sleep('2 minutes');
  const b = yield* liveDns; // из кеша
  yield* Effect.sleep('4 minutes');
  const c = yield* liveDns; // снова резолвит, TTL истёк
});

Опять, без ключа: это кеш на один конкретный эффект (dnsLookup для конкретного api.example.com). Подходит для глобальных одиночек: список фич, конфиг, сертификат, который надо обновлять раз в 5 минут.

Effect.cachedInvalidateWithTTL, ручной reset

Такой же cachedWithTTL, плюс отдельный эффект для ручного сброса кеша:

import { Effect } from 'effect';

const setup = Effect.gen(function* () {
  const [getConfig, invalidate] = yield* Effect.cachedInvalidateWithTTL(
    loadConfig,
    '1 hour',
  );

  return { getConfig, invalidate };
});

Полезно для сценария “конфиг живёт час, но при SIGHUP сбрасываем сразу”. invalidate это Effect<void>, его можно повесить на сигнал через 05 · Resources.

Cache.make, основной инструмент с ключом, TTL и capacity

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

import { Cache, Duration, Effect } from 'effect';

const program = Effect.gen(function* () {
  const dnsCache = yield* Cache.make({
    capacity: 1024,
    timeToLive: Duration.minutes(5),
    lookup: (host: string) => dnsLookup(host),
  });
  // dnsCache: Cache<string, string, DnsError>

  const a = yield* Cache.get(dnsCache, 'api.com'); // резолвит, кладёт
  const b = yield* Cache.get(dnsCache, 'api.com'); // из кеша
  yield* Effect.sleep('6 minutes');
  const c = yield* Cache.get(dnsCache, 'api.com'); // TTL истёк, резолвит заново
});

Заметь форму вызова: Cache.get(cache, key), а не cache.get(key). Кеш в Effect 4 это данные, а не объект с методами, и все операции живут в модуле. Если встретишь в старом коде точечную запись, это просто другая версия, поведение то же самое.

Что даёт Cache.make:

  • capacity, верхний предел числа ключей. Параметр обязательный: неограниченного кеша, который тихо съест память, тут просто нельзя написать. На переполнении вытесняется старый.
  • timeToLive, общий TTL для всех ключей. Истёкший ключ при get снова идёт в lookup.
  • lookup, эффект, считающий значение по ключу.
  • Cache.get дедуплицирует параллельные вызовы за один ключ: если двое одновременно пришли за 'api.com', lookup вызовется один раз, оба получат тот же результат.
  • Cache.refresh(cache, key), явное обновление по ключу: запустит lookup и обновит запись, не дожидаясь TTL.
  • Cache.invalidate(cache, key), точечный сброс одного ключа.

Это и есть тот самый “правильный” инструмент для DNS-кеша, whois-кеша, geo-ip-кеша. Дальше в Pulse будем его и использовать.

Кеш заявок против кеша значений

Возможно сбило с толку: кеш RequestResolver.withCache (раздел 4) и Cache.make это разные вещи. Кратко:

  • RequestResolver.withCache(resolver, { capacity }) кеширует по значению заявки, ключ это hash Request-а, TTL нет. Живёт на резолвере.
  • Cache.make это полноценный кеш с TTL, capacity, lookup и refresh, живёт сколько живёт владелец. Ключ это любой тип, который ты передал.

Есть и мост между ними: RequestResolver.asCache(resolver, { capacity, timeToLive }) берёт резолвер и отдаёт настоящий Cache, у которого заявка играет роль ключа. Удобно, когда хочется и пакетирование, и TTL с ручной инвалидацией, и не хочется писать два слоя вручную.

Эмпирика выбора:

  • Один тик probeAll, хочешь, чтобы три dnsLookup за тот же host сложились в один: ничего не делай, дедупликация уже работает.
  • Заявки повторяются между тиками и обновлять их не нужно: RequestResolver.withCache.
  • dnsLookup для host живёт 5 минут, чтобы не дёргать DNS на каждом тике: Cache.make с timeToLive: Duration.minutes(5).
  • Глобальный loadConfig, посчитай один раз и забудь: Effect.cached.
  • loadConfig, который надо инвалидировать на SIGHUP: Effect.cachedInvalidateWithTTL.

Тесты на кеш через TestClock

Все эти TTL-механизмы используют Clock сервис из стандартного окружения Effect, а не Date.now() или setTimeout. В тестах подменяешь Clock на TestClock, и время становится управляемым:

import { describe, it } from '@effect/vitest';
import { Cache, Duration, Effect } from 'effect';
import { TestClock } from 'effect/testing';
import { expect } from 'vitest';

describe('dnsCache', () => {
  it.effect('refreshes after TTL', () =>
    Effect.gen(function* () {
      let calls = 0;
      const cache = yield* Cache.make({
        capacity: 16,
        timeToLive: Duration.minutes(5),
        lookup: (host: string) =>
          Effect.sync(() => {
            calls += 1;
            return `ip-for-${host}`;
          }),
      });

      yield* Cache.get(cache, 'a.com');
      yield* Cache.get(cache, 'a.com');
      expect(calls).toBe(1);

      yield* TestClock.adjust('4 minutes');
      yield* Cache.get(cache, 'a.com');
      expect(calls).toBe(1); // ещё в TTL

      yield* TestClock.adjust('2 minutes');
      yield* Cache.get(cache, 'a.com');
      expect(calls).toBe(2); // TTL истёк, заново
    }).pipe(Effect.provide(TestClock.layer())),
  );
});

TestClock подключается обычным Layer-ом из подмодуля effect/testing: Effect.provide(TestClock.layer()). Раньше для этого был отдельный TestContext, теперь тестовые сервисы это такие же Layer-ы, как всё остальное.

TestClock.adjust('4 minutes') сдвигает время на 4 минуты вперёд внутри теста. Реального ожидания нет, тест мгновенный, но кеш ведёт себя точно так же, как в проде. Этот же приём дальше в 11 · Runtime и Schedule применяется для repeat.

Что взять с собой

  • Effect.cached, один эффект, один результат, без TTL.
  • Effect.cachedWithTTL плюс cachedInvalidateWithTTL, тот же сценарий с временем жизни и опционально с ручным сбросом.
  • Cache.make, полноценный per-key кеш с TTL и обязательной capacity, рабочая лошадка. Операции модульные: Cache.get(cache, key).
  • RequestResolver.withCache это кеш по значению заявки, asCache тот же кеш, но с TTL и наружу как Cache.
  • TestClock.adjust(...) управляет временем в тестах, реального ожидания нет.

Раздел 7 · Pulse · DNS-batched, Whois-cached

Сцена

В Pulse у тебя 50 таргетов. На каждом тике probeAll для каждого таргета:

  1. Резолвишь DNS, чтобы передать IP в опцию lookup HTTP-клиента.
  2. Проверяешь whois для домена (раз в сутки достаточно), чтобы понимать, не истёк ли он.
  3. Делаешь HTTP-пробу.

Без батчинга это 50 DNS-запросов, 50 whois-запросов, 50 HTTP-запросов на каждом тике. Whois так делать вообще нельзя: серверы тебя забанят за минуту.

С батчингом и кешем: на типичном тике это 1-2 DNS-пакета (по 8 в каждом, дедупликация снимет ~30 повторов на одинаковых хостах), 0 whois-запросов (всё в кеше с TTL 24 часа), 50 HTTP-проб (их батчить нечем, каждый таргет уникален). Получается на порядок дешевле и быстрее.

Шаг 1 · Request и RequestResolver для DNS

// pulse-<nick>/src/batching/dns-resolver.ts
import { Data, Effect, Request, RequestResolver, Result } from 'effect';
import { promises as dnsLib } from 'node:dns';

export class DnsError extends Data.TaggedError('DnsError')<{
  readonly host: string;
  readonly cause: unknown;
}> {}

// Порядок параметров: сначала поля, потом успех, потом ошибка.
export class ResolveDns extends Request.TaggedClass('ResolveDns')<
  { readonly host: string },
  string,
  DnsError
> {}

const DnsLookupResolver = RequestResolver.make<ResolveDns>((entries) =>
  Effect.gen(function* () {
    const hosts = entries.map((entry) => entry.request.host);
    const outcome = yield* Effect.tryPromise({
      try: () =>
        Promise.all(
          hosts.map((h) =>
            dnsLib.resolve4(h).then(
              (ips) => [h, ips[0] ?? null] as const,
              () => [h, null] as const,
            ),
          ),
        ),
      catch: (cause) => new DnsError({ host: hosts.join(','), cause }),
    }).pipe(Effect.result);

    if (Result.isFailure(outcome)) {
      yield* Effect.forEach(entries, (entry) => Request.fail(entry, outcome.failure), {
        discard: true,
      });
      return;
    }

    const map = new Map(outcome.success);
    yield* Effect.forEach(
      entries,
      (entry) => {
        const host = entry.request.host;
        const ip = map.get(host);
        return ip == null
          ? Request.fail(entry, new DnsError({ host, cause: 'NXDOMAIN' }))
          : Request.succeed(entry, ip);
      },
      { discard: true },
    );
  }),
);

const BatchedDnsResolver = RequestResolver.batchN(DnsLookupResolver, 8);

export const dnsLookup = (host: string) =>
  Effect.request(new ResolveDns({ host }), BatchedDnsResolver);

Что важно:

  • Request.TaggedClass даёт конструктор с дедупликацией по host, hash и equals считаются по полям автоматически.
  • В резолвере батч-операция честная: Promise.all параллельно стучит в DNS, но всё это внутри одного резолвера, и снаружи это один “пакетный” эффект, к которому применяется batchN(8).
  • На сетевую ошибку весь пакет помечается как fail через Request.fail(entry, ...). Это упрощает сценарий “сеть упала, отметить все таргеты как Skipped”.

В DNS-примере “пакетность” частично условная: реальный dnsLib.resolve4 не принимает массив. В промышленном DNS-клиенте (через TCP, или DNS-over-HTTPS, или через bind-resolver) пакетность настоящая. Выигрыш здесь даёт дедупликация и ограничение конкурентности: batchN(8) не пропустит к резолверу больше 8 параллельных запросов одного типа.

Шаг 2 · сервис DnsCache с TTL

// pulse-<nick>/src/batching/dns-cache.ts
import { Cache, Context, Duration, Effect, Layer } from 'effect';

import { dnsLookup, type DnsError } from './dns-resolver.ts';

export interface DnsCacheImpl {
  readonly lookup: (host: string) => Effect.Effect<string, DnsError>;
  readonly invalidate: (host: string) => Effect.Effect<void>;
  readonly refresh: (host: string) => Effect.Effect<void, DnsError>;
}

export class DnsCache extends Context.Service<DnsCache, DnsCacheImpl>()('our-pulse/DnsCache') {}

export const DnsCacheLive = Layer.effect(
  DnsCache,
  Effect.gen(function* () {
    const cache = yield* Cache.make({
      capacity: 1024,
      timeToLive: Duration.minutes(5),
      lookup: (host: string) => dnsLookup(host),
    });

    return DnsCache.of({
      lookup: (host) => Cache.get(cache, host),
      invalidate: (host) => Cache.invalidate(cache, host),
      refresh: (host) => Cache.refresh(cache, host),
    });
  }),
);

Форма объявления сервиса тут стандартная для Effect 4: Context.Service<Self, Shape>()('id') для тега и отдельный Layer.effect для реализации. Раньше эти две вещи склеивались в один Effect.Service, который сам порождал слой .Default; теперь тег и слой разведены, и слой ты называешь сам.

По сути Cache.make оборачивает dnsLookup (который сам по себе батчит через Request) ещё одним слоем кеша на 5 минут. Получается двухуровневая защита:

  • в рамках одного окна сборки идентичные host дедуплицируются за счёт Request плюс батчатся за счёт RequestResolver.make;
  • между тиками (5 минут) ответ берётся из Cache.make без обращения к резолверу вообще.

Шаг 3 · сервис Whois с TTL 24 часа

Whois это медленная операция, и серверы whois активно банят за частые запросы. TTL 24 часа это компромисс: не свежее, но и не ловишь rate-limit:

// pulse-<nick>/src/batching/whois.ts
import { Cache, Context, Data, Duration, Effect, Layer } from 'effect';

import { WhoisClient, WhoisClientLive } from './whois-client.ts';

export class WhoisError extends Data.TaggedError('WhoisError')<{
  readonly domain: string;
  readonly cause: unknown;
}> {}

export type WhoisRecord = {
  readonly registrar: string;
  readonly expiresAt: Date;
};

export interface WhoisImpl {
  readonly lookup: (domain: string) => Effect.Effect<WhoisRecord, WhoisError>;
  readonly refresh: (domain: string) => Effect.Effect<void, WhoisError>;
}

export class Whois extends Context.Service<Whois, WhoisImpl>()('our-pulse/Whois') {}

export const WhoisLive = Layer.effect(
  Whois,
  Effect.gen(function* () {
    const client = yield* WhoisClient;

    const cache = yield* Cache.make({
      capacity: 256,
      timeToLive: Duration.hours(24),
      lookup: (domain: string) =>
        client.fetch(domain).pipe(
          Effect.mapError((cause) => new WhoisError({ domain, cause })),
        ),
    });

    return Whois.of({
      lookup: (domain) => Cache.get(cache, domain),
      // refresh, чтобы CLI-команда `pulse whois refresh` дёргала вручную
      refresh: (domain) => Cache.refresh(cache, domain),
    });
  }),
).pipe(Layer.provide(WhoisClientLive));

dependencies в объявлении сервиса тоже больше нет: зависимость закрывается обычным Layer.provide на самом слое.

Важная деталь: для whois Request плюс RequestResolver не нужны. Причин две:

  • whois-клиент по протоколу не умеет батчить (один TCP-запрос на один домен);
  • при TTL 24 часа реального параллелизма за один тик практически не бывает (всё уже в кеше).

Cache.make сам по себе достаточен. Это иллюстрирует, что Request/Resolver и Cache.make это независимые инструменты, каждый под свою задачу: первый про “пакет одного типа в одной фазе”, второй про “значение по ключу с TTL”. Их можно использовать вместе (DNS) или по отдельности (Whois).

Шаг 4 · сборка в MainLive

// pulse-<nick>/src/layers.ts
import { Layer } from 'effect';

import { DnsCacheLive } from './batching/dns-cache.ts';
import { WhoisLive } from './batching/whois.ts';
import { MonitorEventsPubSubLive, UrlsToProbeQueueLive } from './concurrency/coordination.ts';
import { HttpClientLive } from './services/http-client.ts';
import { SlaLive } from './concurrency/sla-state.ts';

export const MainLive = Layer.mergeAll(
  HttpClientLive,
  MonitorEventsPubSubLive(),
  UrlsToProbeQueueLive(),
  SlaLive,
  DnsCacheLive,
  WhoisLive,
);

DnsCacheLive и WhoisLive встают в граф наряду с очередью и шиной событий (см. 07 · Координация) и Sla (см. 08 · Транзакции). Подключаются на старте, scope живёт до завершения программы, никаких ручных dispose.

Шаг 5 · использование в worker-е

// pulse-<nick>/src/workers/probe.ts
import { Effect, PubSub, Result } from 'effect';

import { DnsCache } from '../batching/dns-cache.ts';
import { Whois } from '../batching/whois.ts';
import { MonitorEventsPubSub } from '../concurrency/coordination.ts';
import { HttpClient } from '../services/http-client.ts';

const probeOne = (target: { id: string; host: string; url: string }) =>
  Effect.gen(function* () {
    const dns = yield* DnsCache;
    const whois = yield* Whois;
    const http = yield* HttpClient;
    const bus = yield* MonitorEventsPubSub;

    const ip = yield* dns.lookup(target.host).pipe(Effect.result);
    if (Result.isFailure(ip)) {
      yield* PubSub.publish(bus, { _tag: 'ProbeSkipped', targetId: target.id, reason: 'dns' });
      return;
    }

    const whoisRecord = yield* whois.lookup(etldPlusOne(target.host)).pipe(Effect.result);
    if (Result.isSuccess(whoisRecord) && whoisRecord.success.expiresAt < new Date()) {
      yield* PubSub.publish(bus, { _tag: 'ProbeSkipped', targetId: target.id, reason: 'expired' });
      return;
    }

    const response = yield* http.get(target.url).pipe(Effect.result);
    yield* PubSub.publish(
      bus,
      Result.isSuccess(response)
        ? { _tag: 'ProbeSuccess', targetId: target.id, status: response.success.status }
        : { _tag: 'ProbeFailure', targetId: target.id, cause: response.failure },
    );
  });

export const probeAll = (targets: ReadonlyArray<{ id: string; host: string; url: string }>) =>
  Effect.forEach(targets, probeOne, { concurrency: 'unbounded' });

Никакого переключателя батчинга в прикладном коде нет: все Effect.request внутри probeOne собираются рантаймом в пакеты сами. dns.lookup под капотом это Cache.get, при промахе Effect.request(new ResolveDns({ host }), BatchedDnsResolver), и пакет соберётся для всех промахов сразу.

Заодно посмотри на публикацию событий: bus.publish(x) стало PubSub.publish(bus, x). PubSub в Effect 4 это данные, а операции живут в модуле, ровно как у Cache (07 · Координация).

Шаг 6 · тест на N+1

// pulse-<nick>/test/dns.test.ts
import { describe, it } from '@effect/vitest';
import { Effect } from 'effect';

import { DnsCache, DnsCacheLive } from '../src/batching/dns-cache.ts';

describe('DnsCache', () => {
  it.effect('100 параллельных вызовов схлопываются в один пакет', () =>
    Effect.gen(function* () {
      const dns = yield* DnsCache;

      const targets = Array.from({ length: 100 }, (_, i) => `host-${i % 10}.com`);
      // 100 вызовов, но только 10 уникальных host-ов

      yield* Effect.forEach(targets, (host) => dns.lookup(host), {
        concurrency: 'unbounded',
      });

      // см. assertion на счётчик в стабе lookup: он вызвался 10 раз,
      // а не 100, потому что 90 свернулись дедупликацией по hash и equals,
      // и эти 10 ушли двумя пакетами по 8 и 2 через batchN(8)
    }).pipe(Effect.provide(DnsCacheLive)),
  );
});

Пара слов про @effect/vitest. Тестовый эффект пишется через it.effect, и он уже скоупный: отдельного it.scoped больше нет, он слился с it.effect. Для теста, которому нужно настоящее время вместо виртуального, есть it.live.

В реальном тесте ставишь стаб DnsClient, который считает число вызовов batchResolve(hosts), и проверяешь, что:

  • batchResolve вызвался 2 раза (10 уникальных, по batchN(8));
  • в первом вызове 8 хостов, во втором 2;
  • счётчик “сколько раз внутри batchResolve реально пошёл в сеть” равен 10 (по числу уникальных).

Что взять с собой

  • Request плюс RequestResolver.make плюс batchN(8) плюс Cache.make дают двухуровневую защиту: дедупликация и пакетирование в одной фазе, кеш с TTL между фазами.
  • DnsCache использует и то, и другое; Whois обходится только Cache.make, потому что батчить нечего.
  • В MainLive оба слоя встают рядом с уже знакомыми из 07 и 08.
  • В прикладном коде помнить нечего: батчинг работает сам, а настраивается на резолвере.
  • Тест на N+1 это сравнение счётчика вызовов “сколько раз ушло в сеть” с числом уникальных заявок: если совпало, всё работает.

Финал · чек-лист

Чек-листготово

ДЗ

Дальше

Следующий урок · 11 · Runtime и Schedule. После батчинга и кеша остаётся последний слой production-стека: расписание (как часто стучать), retry с экспонентой (что делать при провале), timeout с fallback (что делать, когда ответа нет). Тогда у Pulse будет полный круг.

Контекст по Layer и сервисам в 04 · Services и Layer: DnsCache и Whois встают рядом с очередью, шиной событий и Sla. Stream поверх результатов в 09 · Stream: один шаг конвейера это пачка Effect.request, и Stream.mapEffect(..., { concurrency }) группирует их так же, как Effect.forEach. Тесты на TTL подробнее в 13 · Testing.