Третий раз
Одну и ту же систему приходилось строить трижды, в трёх разных компаниях, для трёх разных провайдеров. У неё никогда не было названия, и она никогда не появлялась в roadmap, но каждый раз всё шло по одному сценарию: правда о собственных пользователях хранится в чужой базе данных. Пользователи живут в identity-провайдере, подписки — в Stripe, отказы доставки почты — там, откуда отправляются письма, а продукту эта правда нужна локально. Поэтому подписываются на вебхуки и хранят копию данных у себя.
В первый раз казалось, что строится просто endpoint: один route, который парсит JSON и обновляет строку в таблице — работа на полдня.
Полдня превратились в неделю. Сначала появилась проверка подписи, потому что открытый endpoint, изменяющий базу данных, — это дыра в безопасности. Затем таблица дедупликации, потому что доставки приходят дважды, а в документации это бодро называется «at-least-once». Затем у обработчика появился буфер, потому что membership.created иногда приходит раньше, чем user.created, на который он ссылается. Затем появился импортёр для первоначальной загрузки данных, потому что вебхуки сообщают только о том, что происходит после подписки, и этот импортёр гонялся наперегонки с живыми событиями, так что обзавёлся собственной схемой блокировок. И наконец появился cron для сверки данных — задание, которое в 3 часа ночи обходит list API провайдера, сравнивает результат с локальными таблицами и тихо исправляет несовпадения.
Стоит быть честным насчёт того, что представляет собой этот cron. Это письменное признание. Оно говорит: я не доверяю копии, которую построил, и у меня нет способа узнать, когда она неверна, поэтому я буду пересобирать её с нуля каждую ночь, вечно.
Доверие исчезло не просто так. Расхождение данных никогда не заявляет о себе само; в этом случае его обнаружили через тикет в поддержку. Клиент отменил подписку несколькими месяцами ранее, а в базе данных всё ещё стояло active; какое-то событие customer.subscription.deleted испарилось между Stripe и системой, и ничто и никто не мог это заметить: ни их дашборд, который показывал доставку как повторённую и в итоге отброшенную, ни собственные логи, которые не способны залогировать запрос, который никогда не приходил.
И это лишь код. Каждый провайдер приносит с собой ещё и собственный дашборд. Три провайдера — три страницы настройки вебхуков, и у каждой своё представление о том, как регистрируются endpoint'ы, какие события существуют, как разделяются тестовая и рабочая среды и где хранится секрет для подписи. Когда что-то ломается, дебаг превращается в экскурсию: лог доставки провайдера в одной вкладке, собственные логи в другой, и третья вкладка — для того дашборда, на который сейчас падает подозрение. Ни один не похож на другой, и проверять приходится все.
К третьему разу, когда пришлось строить эту систему, притворяться уже не было смысла. Весь стек (подписи, дедупликация, буферизация, первоначальная загрузка, cron) был заложен в план с самого начала, и где-то посреди написания третьей по счёту таблицы дедупликации наконец возник вопрос, который следовало задать с самого начала.
Что вообще здесь восстанавливается?
Уведомления — это не данные
Восстанавливался упорядоченный лог. Каждая из этих интеграций была попыткой превратить поток уведомлений обратно в упорядоченную, полную, актуальную историю, из которой он и был получен.
И вот в чём абсурд: эта история существует. Она обязана существовать, потому что находится внутри провайдера — именно так рендерятся его дашборды, страницы событий и инструменты повторной отправки вебхуков. Провайдер берёт свой упорядоченный лог, разрывает его на отдельные HTTP POST-запросы, отправляет их на endpoint по каналу, который не гарантирует ни порядок, ни доставку, — а затем лог собирается заново на другой стороне. Так делает и каждый другой потребитель, независимо, со своими собственными багами.
Это похоже на пазл, где у производителя была оригинальная картинка, он разрезал её, прислал кусочки по одному, часть потерял по дороге, часть отправил дважды и ничего не напечатал на коробке. А когда собранный пазл не совпадает с оригиналом, служба поддержки спрашивает у вас, каких кусочков не хватает. Ответа нет, и это вся суть проблемы: ничто не сигнализирует о пробеле.
Ни один из этих случаев не является багом провайдера. Их вебхуки работают именно так, как задокументировано. Проблема в том, что такое вебхук по своей природе: уведомление — «что-то произошло, вот POST об этом». Уведомления отлично подходят для запуска побочного эффекта и ужасно — для передачи набора данных, и в какой-то момент их начали использовать для второй задачи, не заметив, что сменили род занятий.
Как это стало нормой?
Никто не принимал такого решения специально. Термин «webhook» придумал Джефф Линдси в 2007 году, и первые применения были действительно удачными: post-receive хуки GitHub запускали CI-сборку, или платёжное событие пинговало сервер, чтобы отправить квитанцию по email. Задача была сделать что-то в момент, когда что-то происходит, и для этого POST-запрос идеален: fire-and-forget вполне подходит, когда забыть — не страшно.
Вебхуки распространились, потому что были самым дешёвым решением для провайдера (один HTTP POST) и самым дешёвым для потребителя (веб-сервер уже был, оставалось просто добавить route). К началу 2010-х «у нас есть вебхуки» стало галочкой на лендинге каждого API, и эта галочка никогда не различала две совершенно разные задачи:
- Запустить побочный эффект: отправить квитанцию, начать сборку, пингануть канал.
- Держать копию данных провайдера корректной: клиент удалил способ оплаты — обновите это и в своей базе.
Первая задача — то, для чего вебхуки были созданы. Вторая — то, чем приходилось заниматься все три раза, и именно для неё критично всё, чего вебхукам не хватает: порядок, полнота, возможность начальной загрузки, верифицируемость.
Инструмент выбрали тот, что лежал на столе в 2007 году, а потом пятнадцать лет компенсировали его недостатки.
Долина
Есть концепция из эволюционной биологии, которая никак не выходит из головы: ландшафт приспособленности (fitness landscape). Пики — это удачные решения, впадины — неудачные, и популяции взбираются по тому склону, на котором оказались. Ловушка — это локальный оптимум: небольшой холм, который лучше, чем непосредственное окружение, поэтому эволюция там и остаётся, даже когда через долину есть гораздо более высокий пик. Чтобы добраться до этого пика, нужно пройти через решения, которые временно хуже, а эволюция не умеет действовать «временно хуже».
Вебхуки как способ репликации данных — локальный оптимум, и доказательство этому — куча обходных решений на дне долины: схемы подписи, хранилища для дедупликации, идемпотентные обработчики, очереди повторов с экспоненциальной задержкой на стороне провайдера и dead-letter очереди позади них, логи вебхуков с инструментами повторной отправки, потому что потребители постоянно просят повторить доставку, и тот самый cron в 3 часа ночи.
Над этой кучей выросла целая экономика. Svix существует, чтобы провайдерам не приходилось строить доставку вебхуков самим; Hookdeck существует, чтобы потребителям не приходилось строить приём вебхуков самим. AWS продаёт эту долину в виде управляемых сервисов: EventBridge для приёма событий от SaaS-партнёров, SQS для их постановки в очередь и Lambda для повторных попыток вызова обработчика — а собрать весь пайплайн предлагается самостоятельно. И целая индустрия коннектор-платформ (Fivetran, Airbyte, любой стартап с «унифицированным API») — это, по сути, псевдо-CDC: change data capture, воссозданный из вебхуков и опросов list API, коннектор за коннектором, продаваемый как продукт. Внутри одной базы данных отслеживание изменений — решённая задача: это называется репликацией, и она работает, потому что есть лог. Между компаниями его пересобирают заново из дверных звонков.
Самый любимый из всех обходных путей — локальный туннель. Многие провайдеры поставляют CLI вроде stripe listen, который открывает туннель к ноутбуку разработчика, потому что вебхук не может достучаться до localhost. Стоит задуматься, что это такое: продукт, созданный и поддерживаемый провайдером, изобретённый повторно много раз разными компаниями, единственная цель которого — обойти направление доставки их же собственного примитива. Когда нескольким провайдерам одновременно приходится поставлять локальный туннель, чтобы разработчики могли разрабатывать, это значит, что примитив отвечает не на тот вопрос.
Все эти инструменты — не плохая инженерия, а отличная инженерия. Именно так и выглядит локальный оптимум: столько отличной инженерии влито в дно долины, что долина становится удобной, и никто не смотрит вверх.
Но некоторые провайдеры уже посмотрели вверх. Stripe хранит тридцать дней событий и предоставляет /v1/events — упорядоченный, доступный для листинга лог, и рекомендует сверяться с ним. WorkOS предлагает Events API — упорядоченный лог с курсорной пагинацией, и собственная документация WorkOS рекомендует его вместо вебхуков, когда важна консистентность данных. Лог постоянно норовит вырваться наружу, и каждый такой случай порождает свою собственную семантику курсоров, свой способ начальной загрузки, отсутствие способа верифицировать реплику и отсутствие общего контракта — но направление угадывается безошибочно. Это конвергентная эволюция: разные организмы, одинаковое давление среды, одно и то же крыло.
Лог существует повсюду, но контракта на него — нет.
Могло ли быть лучше?
Прежде чем предлагать новую конструкцию, стоит спросить, что вообще должна обеспечивать любая замена. Три собственных интеграции подсказывают список: порядок, чтобы изменения можно было применять без буферизации; способ начать с нуля, чтобы первоначальная загрузка не была отдельным импортом, гонящимся за живыми событиями; удаления как данные, чтобы отсутствие перестало быть режимом отказа; возможность продолжить с места остановки, чтобы простой был собственной проблемой, а не событием потери данных; и какой-то способ верифицировать результат, чтобы доверие не деградировало до cron-задания в 3 часа ночи.
Если сверить с этим списком, очевидные кандидаты не дотягивают. Более агрессивный опрос list API — это тот же cron сверки, повышенный до целой стратегии: он способен восстановить текущее состояние, но тратит лимиты запросов, обнаруживая, что в основном ничего не изменилось, ничего не говорит о порядке, а удалённый объект выглядит идентично объекту, которого никогда не существовало. Управляемая доставка, будь то Svix на стороне провайдера или EventBridge и SQS на стороне потребителя, делает push-уведомления надёжнее, но это всё равно push: нет начальной загрузки, нет верификации, уведомления всё так же притворяются набором данных. Этот путь укрепляет дно долины, но никуда не поднимает.
Третий кандидат — тот, что провайдеры уже наполовину строят сами: перестать пушить вообще и позволить потребителю самому читать лог.
Развернуть стрелку
Отсюда мысленный эксперимент. Что если вместо того, чтобы провайдер сообщал о появлении новой информации, спрашивать у провайдера, какая новая информация у него появилась с последней проверки?
Представим, что провайдер отдаёт один URL на коллекцию, и этот URL возвращает упорядоченный, адресуемый по курсору лог изменений с событиями полного состояния. Запрос без курсора — чтение с самого начала, это и есть начальная загрузка, без отдельного импорта и без гонки. Запрос с курсором — продолжение с того места, где остановились. Всё состояние синхронизации — это один курсор.
GET /feed/customers?cursor=01J9XQ4R
Prefer: stream
200 OK
Content-Type: application/x-ndjson
{"cursor":"01J9XR2M","operation":"upsert","object":{"id":"cus_123","plan":"pro"}}
{"cursor":"01J9XR2N","operation":"delete","object_id":"cus_099"}С заголовком Prefer: stream ответ никогда не заканчивается: каждое изменение приходит по мере коммита, по соединению, которое открыл сам потребитель, с тем же API-ключом, что используется для обычных REST-endpoint'ов. Без этого заголовка приходит ограниченная страница, которую можно опрашивать из cron. Это один и тот же endpoint, одни и те же события, одни и те же курсоры и один и тот же код на стороне потребителя.
Ничего экзотического — это обычный GET с пагинацией. Но если пройти назад через тот самый разросшийся день работы, видно, что это делает со стеком:
- Таблица дедупликации исчезает. Каждое событие несёт полное текущее состояние объекта, так что применение события — это слепой upsert по id, и одно и то же событие, применённое дважды, даёт тот же результат.
- Буфер порядка исчезает, потому что лог упорядочен.
- Импортёр для первоначальной загрузки и его схема блокировок исчезают. Новый потребитель читает тот же feed без курсора, воспроизводит коллекцию и без разрыва переходит к живым изменениям в рамках одного запроса.
- Потерянное удаление становится невозможным. Tombstone — это событие в логе, и оно там лежит, пока его не прочитают. Отменённый клиент не может тихо остаться
active, потому что отсутствие данных перестало быть режимом отказа. - Endpoint, подписи и туннель вообще не появляются на свет. Каждое соединение инициируется потребителем, и цикл работает за NAT, на ноутбуке или в scheduled job.
Feed мог бы нести ещё одну вещь. Когда чтение достигает конца лога, провайдер мог бы сообщить, что должно там быть: количество и чек-сумму текущего состояния на том курсоре, который сейчас у потребителя. Сравнение этих двух значений даёт знание, что реплика верна, а не предположение об этом. Cron в 3 часа ночи, то самое письменное признание, превращается в сравнение, которое уже произошло к тому моменту, когда о нём стоило бы подумать.
Четвёртый раз
Если бы такой feed существовал, четвёртый раз построения этой системы свёлся бы к циклу: GET к feed, «upsert» вставляет объект в базу, «delete» удаляет его, сохраняется последний курсор. Двадцать строк кода без route, без секретов для ротации, без очереди и без cron. Реплика несёт собственное доказательство корректности, и когда кто-то спрашивает, у каких клиентов активна подписка и при этом email возвращает отказы, ответ — это JOIN по локальным таблицам без молчаливой звёздочки-примечания.
Сегодня такой feed никто не отдаёт. В этом и загвоздка — и в этом же весь смысл.
SCROLL
Чтобы проверить, выдержит ли эта идея точную формулировку на бумаге, был составлен черновик протокола: SCROLL — сокращение от Synchronized Change Replication Over Line Logs, доступный по адресу welidev.github.io/scroll. Это draft-00 в том смысле, в каком это слово используется в request-for-comments. Документ фиксирует устройство feed, курсоры, режимы стриминга и опроса, контрольные точки, tombstone'ы и политику хранения, и отмечает места, где собственная уверенность автора наименьшая. Протокол также не требует ожидания провайдеров, поскольку прокладка-шим может синтезировать feed из существующих вебхуков и list API любого провайдера — именно так планируется выяснить, где конструкция неверна.
Если вы жили в этой долине, если писали таблицу дедупликации, дебажили cron сверки или наблюдали, как удаление испаряется, прочитайте протокол и скажите, где он ломается. Возражения — желаемая реакция; молчание — режим отказа.