From 7ea8546d8c7868b178ecd47cb20cd99e9f947582 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 20:55:51 +0300 Subject: [PATCH] =?UTF-8?q?editorial:=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8E=20238?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=81=D1=82=D0=B0=D0=BD=D0=B4=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D1=83=2010/10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editorial/agent-rewrites/238.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editorial/agent-rewrites/238.json b/editorial/agent-rewrites/238.json index 2513ab6..fdf87dd 100644 --- a/editorial/agent-rewrites/238.json +++ b/editorial/agent-rewrites/238.json @@ -1,7 +1,7 @@ { "index": 238, "slug": "editorial-2021-05-field-distributed-locks", - "title": "Поздний владелец lease: как остановить stale write", - "excerpt": "Истёкший lease не останавливает worker, который уже выполняет работу. Разбираем stale write, fencing token и атомарную проверку ресурса на конкретном interleaving.", - "contentHtml": "

В журнале появляется странная последовательность: worker-B записал новое значение, а через несколько секунд worker-A вернул успешный ответ для той же записи. Команда видит два захвата одного lock и начинает менять TTL. Но часто provider не нарушал контракт. Worker-A получил lease, остановился на паузе, дождался expiry и продолжил работу. Ресурс принял его позднюю запись, потому что ничего не знал о поколении lock. Цена ошибки — откат состояния, повторная отправка платежа или потеря результата более нового worker.

\n

Тезис статьи простой: distributed lock координирует владельцев, но не защищает внешний ресурс от уже запущенного старого владельца. Для этой границы нужен fencing token. Worker передаёт token вместе с записью. Ресурс хранит последний принятый token и отклоняет меньший. Учебные значения, workers и времена ниже вымышлены. Пример работает в памяти и не доказывает поведение конкретного кластера.

\n

Сначала разделите две ответственности

\n

Lock authority отвечает на вопрос: «кому выдать следующее владение именем?». Lease даёт этому владению срок жизни. Keepalive продлевает срок, пока authority получает подтверждения от клиента.

\n

Защищаемый ресурс отвечает на другой вопрос: «может ли этот запрос изменить состояние после уже принятого поколения?». Здесь живёт compare-and-set, условный UPDATE, версия строки или другая транзакционная проверка. Если ресурс не выполняет такую проверку, token остаётся полем в логе.

\n
Поколения владельцев на одной записи
СобытиеСостояние authorityСостояние ресурсаОжидаемое решение
A получил token 1lease-A активенпоследний token меньше 1можно начать работу
lease-A истёкимя можно выдать сноваA может ещё выполнять кодне считать A остановленным
B получил token 2lease-B активенtoken 2 ещё не принятпередать token 2 в write
B записал значениеauthority может не участвоватьhighest token равен 2сохранить значение B
A прислал token 1lease-A уже недействителенhighest token равен 2отклонить stale write
\n

Как возникает stale write

\n

Проверка lease в начале функции защищает только момент проверки. Она не переносится на будущую запись. Пауза может возникнуть из-за GC, медленной базы, debugger, сетевого ожидания или планировщика. За это время authority выдаст новое владение. Локальная переменная A всё ещё содержит старый leaseId, поэтому код продолжит работу, если перед write нет отдельного барьера.

\n
t=0   A: acquire(lock) -> token=1, lease=active\nt=4s  A: строит результат и останавливается\nt=5s  authority: lease A истёк\nt=5s  B: acquire(lock) -> token=2\nt=6s  B: write(item, token=2) -> accepted\nt=7s  A: write(item, token=1) -> должен быть rejected
\n

Последняя строка не исправляется повторной проверкой lock в A. Между такой проверкой и фактическим write снова появится окно. Ресурс должен проверить token в той же операции, которая меняет его состояние.

\n

Минимальная защита на стороне ресурса

\n

Учебная модель хранит два поля: значение и highest accepted token. Новый запрос проходит только при token больше сохранённого. Сравнение и запись должны быть атомарными относительно других writers. В SQL это обычно означает условие в одном UPDATE и проверку числа изменённых строк.

\n
UPDATE jobs\nSET result = :result, accepted_fence_token = :token\nWHERE job_id = :job_id\n  AND accepted_fence_token < :token;
\n

Если UPDATE изменил ноль строк, запрос не получил право менять ресурс. Причины нужно различать: token мог быть старым, запись могла исчезнуть, а запрос мог повториться с тем же token. Не превращайте все нулевые результаты в retry. Повтор старого token не станет новым от повторной доставки.

\n

Равенство тоже важно. Условие <= отвергает повтор с тем же поколением. Это не решает идемпотентность бизнес-операции. Для неё нужен отдельный operation id и журнал уже применённых эффектов. Owner, fence token и idempotency key отвечают на разные вопросы.

\n
Дерево диагностики stale write: фиксируются resource key и token, затем различаются старый token, повтор операции, ошибочный release и отсутствие fencing
Диагностика начинается с состояния ресурса: без сохранённого highest token нельзя доказать, что поздний запрос был безопасно отклонён.
\n

Симптом → причина → проверка → действие

\n
Матрица разбора конкурентной записи
СимптомПричинаПроверкаДействие
Старый worker пишет после новогоРесурс не проверяет tokenНайти highest token до и после writeДобавить атомарное сравнение при записи
Старый worker снимает lock Brelease ищет только по lock nameСверить ownerId, leaseId и tokenОсвобождать только конкретный handle
Один результат применился дваждыПовтор доставки, а не stale generationСравнить operation id и effect ledgerВвести отдельный idempotency contract
Оба запроса отклоненыToken относится не к той resource keyСопоставить ключ конфликта и линию поколенийСузить область lock и fencing до одного эффекта
Ошибку видят только по времени логовНет состояния решения на ресурсеПроверить запись принятого tokenЛогировать решение рядом с условным write
\n

Release тоже должен быть условным

\n

После expiry A может проснуться и отправить release. Если authority удаляет lock только по имени, A способен снять lease-B. Это отдельная ошибка. Fencing защищает запись ресурса, но не делает старый release безопасным.

\n
release(lockName, leaseId, ownerId, token)\n  if active.lockName != lockName: reject\n  if active.leaseId != leaseId: reject\n  if active.ownerId != ownerId: reject\n  delete active
\n

Конкретный provider может использовать другой handle. Нельзя переносить названия полей как готовый API. Переносим только инвариант: старый владелец не должен менять состояние нового владельца.

\n

Порядок проверки

\n
  1. Остановите автоматический replay подозрительного request и сохраните reference на исходную операцию по правилам хранения данных.
  2. Зафиксируйте одну resource key, ownerId, leaseId, fence token и highest token непосредственно до решения.
  3. Воспроизведите interleaving: A получает token 1, lease истекает, B получает token 2, B пишет, A возвращается.
  4. Проверьте, что запись B и проверка token 2 происходят атомарно в одной ресурсной границе.
  5. Убедитесь, что поздний token 1 получает явный stale rejection и не меняет значение.
  6. Повторите сценарий для release и проверьте, что handle A не освобождает lease B.
  7. Отдельно проверьте duplicate operation по operation id. Не называйте этот результат доказательством fencing.
\n

Ограничения

\n

Fencing не останавливает worker и не отменяет уже отправленный HTTP-запрос. Он не делает workflow exactly-once. Он не упорядочивает разные resource keys и не защищает внешний API, который не умеет принимать и проверять token. Для нескольких эффектов понадобятся разные контракты: fencing для одной записи, idempotency для повторного вызова, компенсация для уже принятого внешнего эффекта.

\n

Lease тоже не равен сигналу остановки процесса. В документации etcd expiry удаляет связанные ключи и освобождает lock, если сервер не получает keepalive. Документация не обещает отмену локальной функции клиента. Поэтому claim «lease истёк, A больше ничего не сделает» неверен.

\n

Эта статья не проверяет etcd, Redis, ZooKeeper, provider SDK, сеть, clock drift, cluster, нагрузку или production. Учебный interleaving показывает только нужный инвариант. Перед выпуском на реальном ресурсе нужно доказать, что его транзакция действительно отклоняет меньший token.

\n

Критерий готовности

\n

Работа готова, если интеграционный тест на выбранном ресурсе проходит четыре проверки: token 2 принимается; поздний token 1 не меняет значение; повторный token 1 получает явный отказ; старый release не снимает lease нового owner. В отчёте должны быть resource key, token до и после, причина отказа и сохранённое итоговое значение. Если хотя бы одного поля нет, тест подтверждает только наличие lock, но не защиту от stale write.

\n

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

\n" + "title": "Поздний владелец lease: как разобрать stale write без ложного диагноза", + "excerpt": "Когда старый worker пишет после expiry, сначала собираем leaseId, token и highest token ресурса. Такой пакет фактов отделяет stale write от duplicate, неверного release и предположений о времени.", + "contentHtml": "

Симптом в журнале часто выглядит противоречиво: worker-B успешно записал новый результат, а потом worker-A сообщил об успешной обработке того же предмета. Цена поспешного диагноза высока. Можно обвинить provider в «двойном lock», увеличить TTL и пропустить факт, что ресурс принял поздний request без проверки поколения. После следующей паузы ошибка повторится, но лог будет содержать ещё больше лишних объяснений.

\n

Для разбора нужен небольшой пакет доказательств, а не догадка о точности часов. Сохраняем lockName, ownerId, leaseId, fenceToken, порядок учебных событий, highest token ресурса до и после write и результат проверки. Этого достаточно, чтобы отличить stale owner от duplicate операции или старого release. Все workers, lease, token, значения ресурса и времена ниже учебные. Модель работает только с Map и счётчиком времени в памяти: она не запускает etcd, Redis, ZooKeeper, базу, broker, HTTP, сетевой тест, синхронизацию часов или cluster.

\n

Собираем факт до повторного запуска

\n

Первое действие при подозрении на stale write — не делать automatic retry. Если request с token 1 уже пришёл после принятого token 2, повтор token 1 не станет новее. Он должен получить тот же явный отказ. Нужна запись, которая связывает один request с одним поколением и состоянием ресурса в момент решения. В учебном примере это обычный объект; в рабочем коде состав полей зависит от политики данных и от того, где живёт ресурсная транзакция.

\n
const evidence = {\n  lockName: 'report:417:rebuild',\n  ownerId: 'worker-A',\n  leaseId: 'training-lease-1',\n  fenceToken: 1,\n  resourceHighestTokenBefore: 2,\n  result: 'rejected-stale-fence',\n};\n\n// Такой пакет объясняет отказ без вывода о точности реальных часов.
\n

Поле resourceHighestTokenBefore особенно полезно. Если оно уже равно 2, а request несёт 1, причина отказа читается без предположения «worker-A точно спал шесть секунд». Мы видим, что ресурс уже принял более новое поколение. Если highest token ещё 0, ситуация другая: возможно, новый owner не успел записать, либо request ушёл к другому resource key. Эти ветви нельзя склеивать в одну метку lock-error.

\n
Диагностика позднего write
НаблюдениеВероятная границаЧто проверитьСледующее действие
token 1 пришёл после accepted token 2resource-side fencing работаетодин resource key и strict comparisonвернуть stale result, не повторять старый write
A release снимает lock Brelease не связан с handleleaseId и owner current leaseсверять точный lease handle перед delete
оба write принятыresource не хранит или не сравнивает tokenусловие в той же операции, что и writeдобавить resource-side fencing или сузить действие
token 1 и token 2 попали в разные keysневерная область fencingresource key и доменная границасогласовать один key на один конфликтующий эффект
один token повторилсяdelivery/idempotency, не обязательно stale owneroperationId и effect ledgerразобрать duplicate отдельным contract
\n

Таблица намеренно не содержит строку «синхронизировать часы и закрыть задачу». Время помогает восстановить порядок, но fence не должен полагаться на timestamp worker. При записи значение token сравнивается внутри ресурса. Если в диагностике есть только wall-clock логи, а resource не записывает принятую версию, вы не сможете доказать, был ли поздний request опасен или просто пришёл после другого несвязанного события.

\n

Проверьте конкретный interleaving, а не красивую схему

\n

В fixture история короткая и детерминированная. Worker-A берёт training-lease-1 с token 1. Модель переводит clock в 6000 мс и отмечает lease истёкшим. Worker-B получает training-lease-2, token 2 и записывает v2. Потом A возвращается с v1. Ресурс смотрит на свой highest token, а не на память A, и возвращает rejected-stale-fence.

\n
const fixture = runDistributedLockFixture();\nif (!Object.values(fixture.assertions).every(Boolean)) {\n  throw new Error('training lock contract failed');\n}\n\nconsole.log(fixture.timeline.staleFirstWrite.status);\n// rejected-stale-fence
\n

Такой тест не проверяет фактический provider. Он проверяет, что команда не потеряла нужный сценарий в обсуждении. Если кто-то заменит сравнение <= на безусловный write, assertion protectedResourceRetainedNewerValue перестанет выполняться. Если убрать resource check, unsafe contrast покажет старое значение в финале. Это хороший маленький барьер перед интеграционным тестом, но не замена ему.

\n
Вертикальное дерево диагностики: сначала фиксируется resource key и token, затем различаются stale token после более нового принятого token, повтор operation id, неверный release и ресурс без fencing; каждая ветка заканчивается конкретной проверкой или действием
Диагностика начинается с состояния ресурса: без highest token нельзя отличить позднего владельца от другой причины повторной записи.
\n

Не путайте expiry с остановкой процесса

\n

Истечение lease означает только, что authority больше не считает этот захват активным. Оно не завершает функцию в worker-A, не отзывает переменную leaseId из памяти и не гарантирует отмену уже отправленного request. Это особенно заметно, если дорогое вычисление построило payload до паузы, а HTTP-клиент продолжил отправку после неё. Поэтому фраза «lease истёк, значит A уже ничего не сделает» не должна попадать в runbook.

\n

Документация etcd v3.4 формулирует это со стороны lock: при expiry lease относящиеся к нему keys удаляются и lock освобождается. Она не описывает отмену работы клиента и не добавляет проверку к внешнему write. Исторический Chubby paper отдельно называет locks advisory и описывает sequencer, который получатель запроса должен проверить. Эти два источника полезны именно потому, что не скрывают границу в удобной фразе «взяли блокировку».

\n

Старый release — другой симптом

\n

Иногда stale write уже защищён fence, но после него lock неожиданно свободен. Тогда смотрим не на token ресурса, а на release. Worker-A мог сохранить handle старого захвата и после pause послать delete по одному lockName. Если authority принимает такую операцию, A способен снять lease-2 worker-B. Это не отменяет fencing у ресурса, но создаёт новую конкуренцию для последующих worker.

\n
function releaseCurrent(active, request) {\n  const ownsCurrentLease = active.ownerId === request.ownerId\n    && active.leaseId === request.leaseId\n    && active.fenceToken === request.fenceToken;\n\n  return ownsCurrentLease ? { status: 'released' }\n    : { status: 'release-rejected-not-owner' };\n}\n\n// Старый worker-A не должен снять lease-2, выданный worker-B.
\n

В учебной модели release содержит owner, leaseId и token. Модель не даёт старому A удалить активный B и фиксирует release-rejected-not-owner. Конкретный provider может использовать другой handle и другое API; не переносите поля буквально. Инвариант остаётся: release должен быть условным по идентификатору того владения, которое будет освобождено, а не только по имени предмета.

\n

Где fence заканчивается

\n

Fence предотвращает старое поколение write для того ресурса, который его проверяет. Он не делает весь workflow exactly-once, не отменяет письмо, уже принятое внешним API, и не определяет порядок между разными resource keys. Если операция создаёт несколько эффектов, у каждого должен быть свой контракт: один ресурс может держать token, другой — operation id, третий — явное ручное решение. Одна блокировка вокруг всего процесса не заменяет эту работу.

\n
Что fence покрывает, а что остаётся отдельным решением
ВопросПокрывает ли token?Нужный дополнительный механизм
поздний write token 1 после token 2 в одном ресурседа, если ресурс сравнивает token атомарнохранение highest token рядом с write
старый worker снимает новый leaseнетусловный release по текущему handle
повтор одного внешнего вызованетoperation id и idempotency contract получателя
два разных resource key в одном workflowне сам по себеявная модель порядка или компенсации
worker действительно остановлен после expiryнетcancellation/timeout как отдельная локальная дисциплина
\n

Эта граница не делает lock бесполезным. Lease полезен, чтобы не запускать одну дорогую работу одновременно, а fence полезен, чтобы поздняя работа не стала новым состоянием там, где ресурс умеет сравнение. Ошибка начинается, когда один механизм получает чужое обещание. Особенно опасно обобщение «мы используем distributed lock, значит транзакция защищена»: оно скрывает, какой именно write ресурс обязан отклонять.

\n

Маршрут полевого разбора

\n
  1. Остановить только автоматический replay подозрительного request, но сохранить исходный payload reference и диагностические поля по политике данных.
  2. Зафиксировать resource key, ownerId, leaseId, token и highest token ресурса непосредственно до решения; не полагаться только на timestamp логов.
  3. Проверить, что token относятся к одной линии поколений для одного конфликтующего ресурса, а не к двум независимым lockName.
  4. Если ресурс уже принял больший token, вернуть или подтвердить stale rejection и не запускать старую критическую секцию повторно.
  5. Если оба write приняты, найти точку безусловной записи. Исправление должно соединить compare token и изменение состояния в одной операции.
  6. Отдельно проверить release: старый handle не имеет права освободить lease нового worker.
  7. Отделить duplicate operation от stale generation по operation id и effect ledger, затем добавить конкретный interleaving в fixture или интеграционный тест.
\n

Что можно утверждать после такой проверки

\n

После успешно пройденного сценария можно сказать узко: ресурс отклонил учебный поздний request token 1 после принятого token 2, а старый handle не освободил новый lease. Нельзя сказать, что provider безопасен при любой сети, часы корректны, кластер выдержал partition или бизнес-эффект exactly-once. Честный результат меньше по масштабу, зато даёт проверяемую границу следующему изменению.

\n

Здесь не запускались реальные etcd/Redis/ZooKeeper, база, provider SDK, сеть, синхронизация времени, cluster, external API, browser, CI, production build, deployment или нагрузка. Нет настоящих пользовательских данных и claim о SLA. Следующий шаг — воспроизвести тот же порядок против конкретного ресурса: зафиксировать способ выдать упорядоченный token, доказать атомарное сравнение на write и проверить поведение старого release. Без этой тройки лог о lock остаётся наблюдением, но не гарантией.

\n

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

\n" }