# Сохраняется ли порядок вызовов в очереди подтверждений при гонке?

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

Это легко не заметить, потому что в обычном сценарии есть одна карточка, один клик и успешный ответ. Дефект проявляется, когда два процесса-агента отправляют запросы почти одновременно, проверяющий подтверждает видимые карточки в неудобной последовательности, а целевая система принимает запрос, который пришёл к ней первым. Я видел, как команды называли такое поведение параллельностью. На деле это неопределённое решение, принятое уже после того, как человек решил, что контролирует ситуацию.

## Порядок начинается, когда шлюз принимает вызов

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

Каждому принятому вызову нужен неизменяемый номер, например `42`, `43` и `44`. Сохраните этот номер вместе с описанием запроса, идентичностью вызывающего процесса, целью и идентификатором линии. Номер должен существовать до того, как шлюз запросит подтверждение. Иначе интерфейс сможет сообщить лишь о том, какой запрос проверяющий увидел первым, а этого недостаточно для восстановления гонки.

Линия - это область, в которой изменение порядка может изменить результат. Два запроса, которые перезаписывают одну среду развёртывания, должны находиться в одной линии. Два вызова, которые добавляют данные в одну удалённую запись инцидента, тоже должны находиться в одной линии. Запрос метаданных пакета и запрос на обновление отдельного тестового сервиса могут не ждать друг друга. Глобальный порядок для всех действий кажется безопасным, но из-за него один медленный несвязанный запрос превращается в сбой для всех агентов.

Сложнее всего честно выбрать линию. Одного имени узла часто слишком много. Одного пути конечной точки часто слишком мало. `PATCH /documents/7` и `POST /documents/7/publish` затрагивают один документ, хотя пути различаются. Если шлюзу не хватает информации, чтобы вывести такую связь, поместите эти действия в одну настроенную линию. Не рассчитывайте, что удалённый сервис исправит обещание о порядке, которого шлюз никогда не давал.

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

```text
ticket=42 lane=release-prod event=accepted action=write-release caller=agent-a
ticket=43 lane=release-prod event=accepted action=write-release caller=agent-b
```

Позже эти строки отвечают на точный вопрос: за какой вызов шлюз первым взял на себя ответственность? Они не утверждают, что номер 42 завершился первым. Медленная цель может привести к тому, что номер 43 завершится раньше или позже, в зависимости от разрешённой модели выполнения. Шлюз должен явно описать эту модель, а не позволять журналу придумать её задним числом.

## Подтверждение - это решение, а не разрешение обойти очередь

Если проверяющий принимает решение по запросу B раньше, чем по запросу A, это не делает B более ранним. Это лишь означает, что B выполнил одно условие и ждёт своей очереди.

Эту разницу легко потерять, потому что интерфейсы подтверждений обычно показывают каждую карточку как отдельный запрос. Для действия без последствий для порядка это работает. Для конкурирующих записей - нет. Если интерфейс оставляет обе карточки доступными, клик по B должен перевести B в состояние «подтверждено, ожидание», а не сразу передать его диспетчеру. Диспетчер проверяет первый номер в линии. Он отправляет только самый ранний запрос, для которого принято решение разрешить выполнение.

Для первого номера в линии у шлюза есть три разумных результата:

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

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

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

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

## Воспроизведите гонку с записями, которые оставляют след

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

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

Этот небольшой сервер Node создаёт обычный журнал поступления:

```js
const fs = require("node:fs");
const http = require("node:http");

http.createServer((request, response) => {
  let body = "";
  request.on("data", chunk => { body += chunk; });
  request.on("end", () => {
    const item = JSON.parse(body);
    fs.appendFileSync("arrival.log", `${item.ticket} ${item.value}\n`);
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ received: item.ticket }));
  });
}).listen(8787);
```

Настройте одно подтверждаемое действие, которое отправляет `POST /write` на эту конечную точку и передаёт номер запроса в теле JSON. Запустите два новых процесса-агента, а не один процесс, который выполняет два вызова последовательно. Каждый процесс отправляет одно и то же действие с отдельным значением, например `A` и `B`. Сделайте так, чтобы оба вызова требовали подтверждения и обе карточки одновременно оставались ожидающими.

Ожидаемые начальные данные должны быть простыми и точными:

```text
Sessions
42 accepted agent-a write A
43 accepted agent-b write B

Approval cards
42 write A
43 write B

arrival.log
(empty)
```

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

Запускайте эту проверку много раз. Сначала запускайте A, затем B, потом меняйте порядок. Запускайте их настолько близко друг к другу, насколько позволяет тестовая обвязка. Добавьте небольшую задержку после входа одного процесса в шлюз, чтобы планирование было менее случайным. Нужны повторяющиеся пересечения, потому что один успешный запуск часто отражает удачное расписание потоков, а не соблюдение правила.

## Порядок карточек, отправки и завершения различается

Тест должен собирать три вида порядка, потому что они отвечают на разные вопросы и их нельзя сводить в одну колонку.

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

Порядок выполнения - это последовательность, в которой шлюз выпускает принятые действия в канал HTTP или SSH. Для строго последовательной линии записи цель должна увидеть номер 42 раньше номера 43. Шлюз должен записать событие `dispatched` непосредственно перед передачей запроса в канал. Не выводите факт отправки из события ответа. Целевая система может получить запись, изменить состояние, а затем потерять соединение до отправки ответа.

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

Короткая тестовая запись не позволяет смешать эти понятия:

```text
42 accepted
43 accepted
42 card-shown
43 card-shown
43 approved
42 approved
42 dispatched
42 succeeded
43 dispatched
43 succeeded
```

То, что `43 approved` появилось раньше `42 approved`, в этом запуске ожидаемо. Контракт заключается в том, что `42 dispatched` появляется раньше `43 dispatched`. Если приложение записывает только конечные успехи, оба факта исчезают, и расследование не сможет установить, подтверждал ли пользователь B первым, обошёл ли диспетчер A или целевая система изменила порядок уже отправленных запросов.

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

## Подтвердите вторую карточку первой и оставьте её ждать

Самая показательная базовая проверка гонки намеренно подтверждает B первым. Она показывает, обрабатывает ли реализация подтверждение как смену состояния или как прямую кнопку отправки.

Начните с номеров 42 и 43, ожидающих решения в одной линии. Подтвердите номер 43. Карточка или журнал должны перейти в состояние, эквивалентное `approved, waiting for 42`. В `arrival.log` по-прежнему ничего не должно быть. Затем подтвердите номер 42. Целевая система должна получить `42 A`, а затем `43 B`, и запись активности должна показать отправку в той же последовательности.

Повторите эту проверку, отклонив номер 42. В журнале цели должен остаться только B. В журнале по-прежнему должны присутствовать оба номера:

```text
42 accepted
43 accepted
43 approved
42 rejected reason=user
43 dispatched
43 succeeded
```

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

Затем проверьте тайм-аут. Дайте подтверждению A истечь, пока B уже подтверждён. Приложение должно один раз записать истечение срока, сделать B доступным и отправить его. Нельзя допустить одновременного появления `expired` и `rejected`, если фоновый таймер и поздний клик столкнулись. Выберите одно конечное решение с помощью атомарной операции сравнения и замены состояния номера. Проигравшее событие должно увидеть, что запрос уже завершён, и ничего не делать.

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

Хороший тест проверяет каждый результат отдельно. Не ограничивайтесь одним утверждением о том, что цель получила ожидаемое конечное состояние. Оно может выглядеть правильным после неправильной последовательности, если B перезаписал A, повторные попытки скрыли дубликат или сама цель применила собственное правило конфликта.

## Сохраняйте номер при повторных попытках и медленных целях

При повторной отправке через канал запрос сохраняет своё место в очереди. Новый номер после сбоя соединения меняет смысл подтверждения и позволяет более позднему запросу обойти его.

Предположим, номер 42 отправлен, TCP-соединение закрывается до получения шлюзом ответа, а целевая система могла применить запись, а могла и не применить. Номер 43 ждёт. У шлюза есть несколько политик: сообщить о неизвестном результате и остановиться, повторить запрос с токеном идемпотентности, если цель его поддерживает, или потребовать нового решения человека. У шлюза нет права молча удалить 42 и отправить 43, словно 42 никогда не существовал.

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

Проверьте это на цели, которая получает A, записывает его в журнал поступления и закрывает ответ до завершения HTTP-обмена. Шлюз должен сохранить примерно такой след:

```text
42 accepted
42 approved
42 dispatched attempt=1
42 outcome=unknown
43 accepted
43 approved waiting-for=42
```

Будет ли номер 43 выполнен позже, это задокументированный операционный выбор. Неприемлемо, когда журнал утверждает, что 42 завершился неудачей без доказательств со стороны шлюза, а затем показывает успешный B, который опирался на это утверждение.

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

## Журнал аудита должен сохранять причинность

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

Записывайте событие принятия до появления любой карточки. Записывайте решение о подтверждении вместе с номером и участником или способом взаимодействия. Записывайте отправку до того, как HTTP-запрос или команда SSH попадёт в свой канал. Записывайте конечный результат канала, не заменяя им более ранние события. Каждому событию нужна собственная позиция добавления в дополнение к реальному времени.

Настенные часы полезны для отладки, но сами по себе не могут определять порядок. Два события могут получить одну временную отметку, часы могут перейти назад, а приложение может поставить записи из разных потоков в очередь. Позиция добавления или порядковый номер устанавливает порядок, в котором журнал принял каждое событие. Номер запроса устанавливает предполагаемый порядок в линии. Сохраняйте оба значения.

Для проверки двух записей сравните следующие факты:

- Позиции принятия показывают, что 42 был принят раньше 43.
- События подтверждения могут показывать 43 раньше 42.
- Позиции отправки показывают 42 раньше 43, если оба вызова успешны.
- Файл поступления цели показывает 42 раньше 43.
- Конечные результаты привязаны к тем же номерам и не заменяют их.

Sallyport строит представления Sessions и Activity из одного зашифрованного журнала аудита с хеш-связями, а `sp audit verify` может офлайн проверить эту цепочку по шифротексту. Поэтому особенно важно правильно выбирать события: проверка может показать, что записи не изменялись, а номера и типы событий помогают человеку понять, что произошло на самом деле.

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

## FIFO относится к конфликтующим эффектам, а не к каждому байту

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

Начните с классификации каждого действия. Команда развёртывания, которая изменяет общую среду, требует отдельной линии. HTTP POST, создающий платёжное действие, тоже требует линии, возможно отдельной для каждого аккаунта. Чтение статического артефакта часто может выполняться независимо. Команда SSH, которая только сообщает о свободном месте на диске, может быть наблюдательной, но будьте осторожны: у команд часто есть скрытые эффекты через стартовые файлы оболочки, временные файлы или оболочки удалённых команд. Считайте неоднозначные команды записями, пока не сможете описать их эффекты.

Популярная альтернатива заключается в том, чтобы считать каждое подтверждение независимым, поскольку проверяющий может изучить запрос. Это привлекательно: не нужно проектировать очередь. Но подход ломается, когда проверяющий понимает каждый запрос по отдельности, однако не видит, что B предполагает уже выполненный A. Человеческая проверка не исправляет отсутствие контекста сериализации.

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

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

## Сделайте гонку критерием выпуска

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

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

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

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