Симптом в журнале часто выглядит противоречиво: worker-B успешно записал новый результат, а потом worker-A сообщил об успешной обработке того же предмета. Цена поспешного диагноза высока. Можно обвинить provider в «двойном lock», увеличить TTL и пропустить факт, что ресурс принял поздний request без проверки поколения. После следующей паузы ошибка повторится, но лог будет содержать ещё больше лишних объяснений.
Для разбора нужен небольшой пакет доказательств, а не догадка о точности часов. Сохраняем lockName, ownerId, leaseId, fenceToken, порядок учебных событий, highest token ресурса до и после write и результат проверки. Этого достаточно, чтобы отличить stale owner от duplicate операции или старого release. Все workers, lease, token, значения ресурса и времена ниже учебные. Модель работает только с Map и счётчиком времени в памяти: она не запускает etcd, Redis, ZooKeeper, базу, broker, HTTP, сетевой тест, синхронизацию часов или cluster.
Собираем факт до повторного запуска
Первое действие при подозрении на stale write — не делать automatic retry. Если request с token 1 уже пришёл после принятого token 2, повтор token 1 не станет новее. Он должен получить тот же явный отказ. Нужна запись, которая связывает один request с одним поколением и состоянием ресурса в момент решения. В учебном примере это обычный объект; в рабочем коде состав полей зависит от политики данных и от того, где живёт ресурсная транзакция.
const evidence = {
lockName: 'report:417:rebuild',
ownerId: 'worker-A',
leaseId: 'training-lease-1',
fenceToken: 1,
resourceHighestTokenBefore: 2,
result: 'rejected-stale-fence',
};
// Такой пакет объясняет отказ без вывода о точности реальных часов.
Поле resourceHighestTokenBefore особенно полезно. Если оно уже равно 2, а request несёт 1, причина отказа читается без предположения «worker-A точно спал шесть секунд». Мы видим, что ресурс уже принял более новое поколение. Если highest token ещё 0, ситуация другая: возможно, новый owner не успел записать, либо request ушёл к другому resource key. Эти ветви нельзя склеивать в одну метку lock-error.
| Наблюдение | Вероятная граница | Что проверить | Следующее действие |
|---|---|---|---|
| token 1 пришёл после accepted token 2 | resource-side fencing работает | один resource key и strict comparison | вернуть stale result, не повторять старый write |
| A release снимает lock B | release не связан с handle | leaseId и owner current lease | сверять точный lease handle перед delete |
| оба write приняты | resource не хранит или не сравнивает token | условие в той же операции, что и write | добавить resource-side fencing или сузить действие |
| token 1 и token 2 попали в разные keys | неверная область fencing | resource key и доменная граница | согласовать один key на один конфликтующий эффект |
| один token повторился | delivery/idempotency, не обязательно stale owner | operationId и effect ledger | разобрать duplicate отдельным contract |
Таблица намеренно не содержит строку «синхронизировать часы и закрыть задачу». Время помогает восстановить порядок, но fence не должен полагаться на timestamp worker. При записи значение token сравнивается внутри ресурса. Если в диагностике есть только wall-clock логи, а resource не записывает принятую версию, вы не сможете доказать, был ли поздний request опасен или просто пришёл после другого несвязанного события.
Проверьте конкретный interleaving, а не красивую схему
В 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.
const fixture = runDistributedLockFixture();
if (!Object.values(fixture.assertions).every(Boolean)) {
throw new Error('training lock contract failed');
}
console.log(fixture.timeline.staleFirstWrite.status);
// rejected-stale-fence
Такой тест не проверяет фактический provider. Он проверяет, что команда не потеряла нужный сценарий в обсуждении. Если кто-то заменит сравнение <= на безусловный write, assertion protectedResourceRetainedNewerValue перестанет выполняться. Если убрать resource check, unsafe contrast покажет старое значение в финале. Это хороший маленький барьер перед интеграционным тестом, но не замена ему.
Не путайте expiry с остановкой процесса
Истечение lease означает только, что authority больше не считает этот захват активным. Оно не завершает функцию в worker-A, не отзывает переменную leaseId из памяти и не гарантирует отмену уже отправленного request. Это особенно заметно, если дорогое вычисление построило payload до паузы, а HTTP-клиент продолжил отправку после неё. Поэтому фраза «lease истёк, значит A уже ничего не сделает» не должна попадать в runbook.
Документация etcd v3.4 формулирует это со стороны lock: при expiry lease относящиеся к нему keys удаляются и lock освобождается. Она не описывает отмену работы клиента и не добавляет проверку к внешнему write. Исторический Chubby paper отдельно называет locks advisory и описывает sequencer, который получатель запроса должен проверить. Эти два источника полезны именно потому, что не скрывают границу в удобной фразе «взяли блокировку».
Старый release — другой симптом
Иногда stale write уже защищён fence, но после него lock неожиданно свободен. Тогда смотрим не на token ресурса, а на release. Worker-A мог сохранить handle старого захвата и после pause послать delete по одному lockName. Если authority принимает такую операцию, A способен снять lease-2 worker-B. Это не отменяет fencing у ресурса, но создаёт новую конкуренцию для последующих worker.
function releaseCurrent(active, request) {
const ownsCurrentLease = active.ownerId === request.ownerId
&& active.leaseId === request.leaseId
&& active.fenceToken === request.fenceToken;
return ownsCurrentLease ? { status: 'released' }
: { status: 'release-rejected-not-owner' };
}
// Старый worker-A не должен снять lease-2, выданный worker-B.
В учебной модели release содержит owner, leaseId и token. Модель не даёт старому A удалить активный B и фиксирует release-rejected-not-owner. Конкретный provider может использовать другой handle и другое API; не переносите поля буквально. Инвариант остаётся: release должен быть условным по идентификатору того владения, которое будет освобождено, а не только по имени предмета.
Где fence заканчивается
Fence предотвращает старое поколение write для того ресурса, который его проверяет. Он не делает весь workflow exactly-once, не отменяет письмо, уже принятое внешним API, и не определяет порядок между разными resource keys. Если операция создаёт несколько эффектов, у каждого должен быть свой контракт: один ресурс может держать token, другой — operation id, третий — явное ручное решение. Одна блокировка вокруг всего процесса не заменяет эту работу.
| Вопрос | Покрывает ли 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 как отдельная локальная дисциплина |
Эта граница не делает lock бесполезным. Lease полезен, чтобы не запускать одну дорогую работу одновременно, а fence полезен, чтобы поздняя работа не стала новым состоянием там, где ресурс умеет сравнение. Ошибка начинается, когда один механизм получает чужое обещание. Особенно опасно обобщение «мы используем distributed lock, значит транзакция защищена»: оно скрывает, какой именно write ресурс обязан отклонять.
Маршрут полевого разбора
- Остановить только автоматический replay подозрительного request, но сохранить исходный payload reference и диагностические поля по политике данных.
- Зафиксировать resource key, ownerId, leaseId, token и highest token ресурса непосредственно до решения; не полагаться только на timestamp логов.
- Проверить, что token относятся к одной линии поколений для одного конфликтующего ресурса, а не к двум независимым lockName.
- Если ресурс уже принял больший token, вернуть или подтвердить stale rejection и не запускать старую критическую секцию повторно.
- Если оба write приняты, найти точку безусловной записи. Исправление должно соединить compare token и изменение состояния в одной операции.
- Отдельно проверить release: старый handle не имеет права освободить lease нового worker.
- Отделить duplicate operation от stale generation по operation id и effect ledger, затем добавить конкретный interleaving в fixture или интеграционный тест.
Что можно утверждать после такой проверки
После успешно пройденного сценария можно сказать узко: ресурс отклонил учебный поздний request token 1 после принятого token 2, а старый handle не освободил новый lease. Нельзя сказать, что provider безопасен при любой сети, часы корректны, кластер выдержал partition или бизнес-эффект exactly-once. Честный результат меньше по масштабу, зато даёт проверяемую границу следующему изменению.
Здесь не запускались реальные etcd/Redis/ZooKeeper, база, provider SDK, сеть, синхронизация времени, cluster, external API, browser, CI, production build, deployment или нагрузка. Нет настоящих пользовательских данных и claim о SLA. Следующий шаг — воспроизвести тот же порядок против конкретного ресурса: зафиксировать способ выдать упорядоченный token, доказать атомарное сравнение на write и проверить поведение старого release. Без этой тройки лог о lock остаётся наблюдением, но не гарантией.
Проверяемые источники
- etcd v3.4: Concurrency API Reference — Lock и Unlock — версионная линия v3.4 существовала до мая 2021 года: Lock возвращает key на время владения, а истечение или отзыв привязанного lease освобождает lock; документация не объявляет этот key универсальным fencing token для внешнего ресурса
- etcd v3.4: API Reference — LeaseGrant и LeaseKeepAlive — LeaseGrant получает advisory TTL; LeaseGrantResponse возвращает TTL, выбранный server. Без keepAlive lease истекает, а прикреплённые keys удаляются; учебная длительность ниже не является настройкой etcd
- Mike Burrows, The Chubby lock service for loosely-coupled distributed systems, OSDI 2006 — первичная публикация: locks advisory, а sequencer передаётся защищаемому серверу, который сам проверяет актуальность и отклоняет устаревший запрос; это исторический пример границы fencing