Курсоры, номера и гарантия доставки, которую мы действительно даём
«Доставка exactly-once» — это функция, которую все просят и которую никто не может дать вам через сеть. Вот гарантия, которую Runnev действительно предлагает: «не менее одного раза» с идемпотентными записями, — почему это честный максимум и как курсоры и номера делают её на практике не хуже exactly-once.
Честный ответ
Доставка через сеть, которая может ронять, переставлять и дублировать пакеты, потребителю, который может упасть между получением сообщения и записью того, что он его получил, не может быть exactly-once на транспортном уровне. Всякий, кто говорит иначе, либо делает дедупликацию где-то, куда вы не смотрите, либо вот-вот потеряет ваши данные в худший момент. Что можно построить — это доставку «не менее одного раза» плюс идемпотентность, и тогда видимое поведение неотличимо от exactly-once для любого потребителя, который дедуплицирует по ключу.
Номера: ключ, который у вас уже есть
У каждого пакета в потоке есть номер, присваиваемый при принятии и никогда не используемый повторно. Этот номер и есть ключ дедупликации. Потребитель, помнящий наибольший полностью обработанный номер, может отклонить всё на уровне этого номера и ниже как дубликат — одним сравнением, без координации и без обращения к серверу.
last = load_cursor() # наибольший полностью обработанный seq, -1 если нет
for batch in runnev.subscribe(stream_id, cursor=last):
if batch.seq <= last: # дубликат от переподключения
continue
handle(batch) # ваш побочный эффект
last = batch.seq
save_cursor(last) # фиксируем ПОСЛЕ побочного эффекта
Порядок последних двух строк — это весь трюк, и на него стоит посмотреть внимательно. Вы фиксируете
курсор после побочного эффекта. Если вы упадёте между handle и
save_cursor, то при перезапуске повторите этот пакет, что безопасно, потому что это «не
менее одного раза», а ваш handle терпит повтор. Если бы вы зафиксировали курсор сначала,
а потом упали, то пропустили бы пакет, что было бы «не более одного раза», и потеряли бы данные. «Не
менее одного раза» — это выбор, и он безопасный.
Идемпотентность и на стороне записи
Та же идея защищает издателя. Публикации привязаны к ключу (stream, seq), поэтому издатель,
выводящий номер из своего монотонного источника — версии строки БД, смещения в логе, растущего
счётчика, — может повторить публикацию после неопределённого таймаута и знать, что результат — один
пакет, а не два. Дубликат обнаруживается сервером и сообщается как "duplicate": true.
Это замыкает круг. Издатель не может случайно записать пакет дважды, а потребитель не может случайно обработать его дважды, и ни одной стороне не понадобилась распределённая транзакция, чтобы этого добиться. Оба свойства вытекают из одного и того же проектного решения: сделать номер значимым и дать обоим концам использовать его как ключ.
Чего мы отказываемся утверждать
Мы не поместим «exactly-once» на маркетинговую страницу, потому что для потребителя, который не делает
дедупликацию, это неправда, а гарантия, которой нужна сноска, чтобы быть правдой, — не гарантия. Что
мы скажем, точно: доставка — «не менее одного раза», записи идемпотентны по (stream, seq),
а потребитель, дедуплицирующий по номеру и фиксирующий курсор после побочного эффекта, получает
обработку exactly-once. Это и есть то свойство, которое вам на самом деле было нужно,
сформулированное так, что оно переживает падение.
Когда вы видите разрыв
Одно следствие, о котором стоит знать: поскольку хранение вытесняет старые пакеты, потребитель, отставший достаточно далеко, может возобновиться с курсора старше старейшего уцелевшего пакета. Тогда он следующим получит старейшего уцелевшего, и номер скакнёт. Этот скачок — не потерянное сообщение, которое можно восстановить; это сигнал, что ваш потребитель был недоступен дольше вашего окна хранения. Относитесь к разрыву в номерах как к алерту, достойному вызова дежурного, и рассчитывайте хранение под худший реалистичный простой, а не под средний.