Перейти к содержанию

Web3: лимиты RPC и управление нагрузкой

Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 28 из 30.

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

Учебная реализация не запускалась и не является готовым RPC-клиентом для производства. Она показывает очередь задач с максимумом параллельных работ. Конкретные лимиты провайдера, стоимость запросов, доступность архивных данных и допустимый размер getLogs необходимо выяснять отдельно. Число два в примере — выбранное ограничение лаборатории, а не рекомендация для любой сети.

Разные запросы имеют разную цену

Чтение одного блока, получение истории большого диапазона и криптографическая проверка контрактного аккаунта требуют разных ресурсов. Нельзя считать все вызовы одинаковыми только потому, что они идут через HTTP. Большой диапазон событий способен занимать больше времени и возвращать больше данных, чем несколько обычных getter-вызовов.

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

Также различаем публичный RPC и сервис доставки. Их rate limit и политика ошибок не обязаны совпадать. Сбой bundler не доказывает отсутствие блока в сети. Сбой исторического getLogs не означает, что текущий getter недоступен. Интерфейс должен локализовать источник неизвестного результата и сохранять успешные данные из других линий с их срезами.

Ограниченная очередь

Построим простой исполнитель. Каждая задача возвращает Promise, а очередь запускает не больше limit работ одновременно. В модели нет приоритетов, таймеров и долговечного восстановления: эти расширения необходимо добавлять отдельно, сохраняя тот же договор завершения задачи.

export function createQueue(limit = 2) {
  if (!Number.isInteger(limit) || limit < 1) throw new Error('Неверный предел');
  let active = 0;
  const waiting = [];

  function drain() {
    while (active < limit && waiting.length > 0) {
      const job = waiting.shift();
      active += 1;
      Promise.resolve()
        .then(job.run)
        .then(job.resolve, job.reject)
        .finally(() => {
          active -= 1;
          drain();
        });
    }
  }

  return {
    add(run) {
      return new Promise((resolve, reject) => {
        waiting.push({ run, resolve, reject });
        drain();
      });
    },
    snapshot() { return { active, waiting: waiting.length }; },
  };
}

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

Размер waiting тоже следует ограничить в полноценном проекте. Ограничение параллелизма без ограничения накопления позволяет накопить тысячи устаревших задач. Для страницы курса 17 полезнее объединить несколько запросов одного среза, чем ставить отдельное чтение после каждого малозначимого события интерфейса.

Объединение одинаковых чтений

Ключ запроса включает environmentId, chainId, адрес контракта, метод, параметры и блок либо другой определённый срез. Тогда два компонента могут разделить одно наблюдение. Ключ без блока способен случайно смешать историческую проверку результата операции с чтением текущей версии.

const inFlight = new Map();
export function sharedRead(key, run) {
  if (inFlight.has(key)) return inFlight.get(key);
  const promise = Promise.resolve().then(run).finally(() => inFlight.delete(key));
  inFlight.set(key, promise);
  return promise;
}

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

Повторяем чтение осмысленно

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

Число попыток, максимальное время и задержку задаём явно. При наличии корректного Retry-After учитываем его в допустимой политике источника. Для большого диапазона событий можно уменьшить порцию, сохраняя непрерывность checkpoint; нельзя перескочить через нечитанную часть только потому, что следующая порция быстрее отвечает.

Повтор чтения отличается от повторной отправки подписанного действия. Планировщик read-запросов не должен содержать кнопку, которая автоматически создаёт новые операции. Если transport.send вернул неизвестный результат, запускаем наблюдение исходного идентификатора. Обычная retry-обёртка вокруг подготовки нового nonce способна породить дополнительное изменение состояния.

Отмена устаревшего отображения

При смене окружения увеличиваем поколение интерфейса. Запущенное чтение может завершиться позже, но его результат не применяется к новой странице, если поколение отличается. Это не отменяет самого сетевого запроса и не возвращает его стоимость; это защита согласованности представления.

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

Ожидаемый результат

Пять задач при limit равном двум должны занимать не более двух активных слотов нашей очереди. После ошибки одной работы следующая начинает выполняться. Два одинаковых sharedRead используют общий Promise. Запрос к другому блоку получает другой ключ и остаётся отдельным наблюдением.

Если endpoint ограничил доступ, карточка показывает недоступность источника, а не обнуляет курс. Если индексатор не прочитал диапазон, его checkpoint не продвигается. Если пользователь переключил окружение, старый ответ не перезаписывает новый срез. Эти ожидаемые свойства дают конкретный смысл ограничению нагрузки: приложение сохраняет согласованность, когда источники работают медленнее, чем пользователь нажимает кнопки.

Предел ожидания внутри задачи

Очередь не решает, сколько времени допустимо тратить на один endpoint. Транспортный адаптер задаёт тайм-аут и, когда API поддерживает это, отменяет запрос. Если отмена невозможна, контроллер всё равно прекращает применять устаревший результат и освобождает прикладное ожидание согласно своему договору.

Однако завершение ожидания не всегда прекращает сетевую работу. Поэтому показатели активных задач приложения нельзя объявлять точной статистикой запросов на стороне провайдера. Отдельно измеряем своё ожидание и фактический ответ источника. Такое различие помогает не скрывать накопление долгих сетевых соединений под красивым маленьким счётчиком очереди.

Оглавление серии