diff --git a/editorial/production/README.md b/editorial/production/README.md
index 08ddd1c..d5945c2 100644
--- a/editorial/production/README.md
+++ b/editorial/production/README.md
@@ -1,6 +1,6 @@
# Производство редакционных партий
-На 31 июля 2026 года строгий аудит проходит 118 из 358 созданных материалов. Остальные 240 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
+На 31 июля 2026 года строгий аудит проходит 121 из 358 созданных материалов. Остальные 237 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
diff --git a/editorial/reviews/2021-05-draft.md b/editorial/reviews/2021-05-draft.md
new file mode 100644
index 0000000..558dfe8
--- /dev/null
+++ b/editorial/reviews/2021-05-draft.md
@@ -0,0 +1,174 @@
+# Автономное тройное ревью П39 · май 2021 · «Блокировки и конкуренция»
+
+Статус: **авторское тройное ревью пройдено, затем пакет принят независимым
+редактором в выпусковой набор**. Revision-модуль содержит ровно три
+стабильных slug:
+
+- editorial-2021-05-practice-distributed-locks;
+- editorial-2021-05-mechanism-distributed-locks;
+- editorial-2021-05-field-distributed-locks.
+
+В revisions нет date, author или
+подключения registry. Эта авторская партия не меняла
+articles.json, README, очередь, стандарт, package config или Git.
+Созданы только пять разрешённых файлов, перечисленных внизу документа.
+
+## Проход 1. Факты, историческая рамка и техника — пройдено
+
+| Утверждение или решение | Первичный / официальный источник | Зафиксированная граница |
+| --- | --- | --- |
+| Для исторической точки мая 2021 выбрана линия etcd v3.4: её Lock возвращает key на время владения; unlock или expiry/revoke связанного lease освобождает lock | [etcd v3.4 Concurrency API Reference](https://etcd.io/docs/v3.4/dev-guide/api_concurrency_reference_v3/) | Документация описывает lock внутри etcd. Она не объявляет возвращённый key универсальным числовым fencing token для внешней базы, файла или API. |
+| LeaseGrant получает advisory TTL, а ответ возвращает TTL, выбранный server; без keepalive lease истекает, attached keys удаляются | [etcd v3.4 API Reference](https://etcd.io/docs/v3.4/dev-guide/api_reference_v3/) | Учебные 5000 мс не являются значением TTL для etcd, не проверяют keepalive, clock drift или clock correctness. |
+| Lock сам по себе advisory; sequencer передаётся получателю операции, а получатель проверяет его актуальность и может отвергнуть request | [Mike Burrows, The Chubby lock service for loosely-coupled distributed systems, OSDI 2006](https://research.google/pubs/the-chubby-lock-service-for-loosely-coupled-distributed-systems/) | Chubby — исторический первичный пример boundary, не скрытая реализация fixture и не рекомендация установить этот сервис. |
+| Выбор v3.4 не заносит будущую версию в текст мая: официальный анонс v3.5 датирован 15 июня 2021 года и ретроспективно называет запуск v3.4 августом 2019 года | [Announcing etcd 3.5](https://etcd.io/blog/2021/announcing-etcd-3.5/) | Эта ссылка служит редакторской проверкой датировки; сама статья не ссылается на будущий для мая анонс и не использует API v3.5. |
+
+runDistributedLockFixture() — детерминированная state machine
+только на Map и ручном счётчике времени. В ней нет системных
+часов, сети или provider client:
+
+1. worker-A получает training-lease-1 и token 1 на 5000 мс;
+2. учебный clock переходит к 6000 мс, lease-1 помечается истёкшим, но
+ worker-A не считается остановленным;
+3. worker-B получает training-lease-2 и token 2;
+4. защищаемый ресурс принимает write worker-B, сохраняет v2 и highest token 2;
+5. поздний write worker-A с token 1 получает
+ rejected-stale-fence;
+6. старый release worker-A не снимает текущий lease worker-B;
+7. отдельный намеренно unsafe ресурс без fencing показывает, что token 1
+ перезаписал бы v2.
+
+Проверены девять assertions: выдача первого lease/token, pause после expiry,
+выдача второго lease/token, принятие v2, отказ stale v1, защита release нового
+lease, сохранение v2, небезопасный контраст без fencing и работа только в
+памяти. Главный технический вывод узкий: **fence помогает только когда
+защищаемый ресурс сам хранит и монотонно проверяет token в своей операции
+записи**. Generic lock не выдан за доказательство безопасности.
+
+Не проверялись реальный etcd/Redis/ZooKeeper, provider SDK, cluster,
+keepalive, сеть, clock synchronization, база, файловый storage, HTTP,
+внешний API, нагрузка или provider SLA. Fixture не заявляет exactly-once,
+отмену старого request и корректность реальных часов.
+
+Вердикт прохода: **пройден**. Историческая версия и ограничения названы рядом
+с механизмом, а модель не переносит свойства учебной Map на настоящий
+провайдер.
+
+## Проход 2. Редактура, глубина и голос М4 — пройдено
+
+| Revision | Симптом и цена в первых двух абзацах | Главный вопрос | Объём основного текста |
+| --- | --- | --- | ---: |
+| Практика | worker-A останавливается дольше lease, B уже обновляет отчёт, A может поздно перезаписать v2 | Как до критической секции разделить lifecycle lease, owner, handle и resource-side fence | **10 939** знаков body |
+| Механизм | поздний request получает успешный ответ после нового владельца, поэтому команда ошибочно обвиняет lock provider | Почему token обязан проверять защищаемый ресурс, а не только authority перед началом работы | **10 293** знака body |
+| Полевой разбор | после v2 в лог попадает «успех» старого A; цена — ложный диагноз и повтор той же гонки | Как собрать evidence и отделить stale generation от duplicate, bad release и безусловного write | **9 857** знаков body |
+
+- Все статьи начинают с наблюдаемого симптома и цены, затем держат маршрут
+ «симптом → причина → проверка → действие → ограничение».
+- В каждой revision есть table с caption/thead,
+ figure с самостоятельными alt/figcaption, не
+ менее двух технических примеров, нумерованный путь, три первичных или
+ официальных источника и явный тестовый interleaving.
+- Голос М4 / мая 2021 продолжает пакеты о транзакции, кешировании и очередях:
+ автор называет owner, handle, ресурсную границу и инвариант, но не
+ изображает себя владельцем большой distributed platform. Речь короткая и
+ предметная: token 1 ≤ highest token 2 → reject, а не
+ «блокировка магически защищает данные».
+- Убраны опасные подмены: expiry не назван остановкой процесса; token не
+ назван clock; lease key не объявлен готовым fence для внешнего ресурса;
+ fencing не отождествлён с idempotency или exactly-once.
+- Заголовки и excerpts обещают ограниченный результат: понять boundary и
+ проверить stale write, а не получить универсальный рецепт distributed lock.
+
+Вердикт прохода: **пройден**. Тексты лежат в диапазоне 5 000–15 000 знаков и
+развивают T-shape автора от delivery/данных к одному проверяемому контракту
+конкуренции без анахронизмов и техлидской позы 2027 года.
+
+## Проход 3. Визуал, fixture и preflight — пройдено в границах пакета
+
+- distributed-lock-lease-timeline-2021.svg показывает полный
+ interleaving от lease-1/token 1 до reject позднего token 1. После первого
+ Sharp-рендера длинная нижняя подпись была разбита на две строки.
+- distributed-lock-fence-boundary-2021.svg отделяет authority от
+ ресурса и показывает, где именно хранится highest token. После первого
+ рендера нижняя карточка получила две короткие строки и большую высоту.
+- distributed-lock-diagnosis-2021.svg ведёт от evidence packet к
+ stale generation, duplicate, resource without compare и bad release. После
+ первого рендера перенесены длинная итоговая подпись и строка atomic compare.
+- У всех трёх SVG есть title, desc,
+ role="img" и вертикальный viewBox. В исходниках нет
+ JavaScript, foreignObject, external URL или data URI; обычный
+ XML namespace не является внешним asset.
+
+### Фактически выполненные проверки
+
+Команды запускались из web/ после финальных правок:
+
+
node --check scripts/upgrade-2021-05.mjs
+npm run audit:draft -- scripts/upgrade-2021-05.mjs
+node scripts/upgrade-2021-05.mjs --verify-fixture
+xmllint --noout \
+ public/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg \
+ public/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg \
+ public/assets/editorial/2021/distributed-lock-diagnosis-2021.svg
+
+| Проверка | Реальный результат |
+| --- | --- |
+| node --check | PASS, code 0 |
+| Import-safe export и draft gate | PASS: итоговые **10 939 / 10 293 / 9 857** знаков body; найдены три slug, sections, tables, figures, code, routes, sources и локальные visual assets |
+| In-memory fixture | PASS: все девять assertions истинны; worker-B получил token 2 после expiry A, ресурс принял v2 и отверг поздний token 1, старый release не удалил lease-B |
+| xmllint --noout | PASS, все три SVG — корректный XML |
+| SVG safety scan | PASS: нет script, foreignObject, external asset URL или raster data URI |
+| Sharp mobile preflight | PASS после корректировки длинных подписей: вручную просмотрены финальные PNG **375×740**, **375×781** и **375×844**; clipping, overlap и horizontal overflow внутри схем не обнаружены |
+| Scope/self-review | PASS: созданы только пять разрешённых файлов; registry, articles.json, README, очередь, стандарт, package config, Git и чужие untracked files не менялись |
+
+npm run audit:draft завершилась с code 0. npm вывел уже
+существующие предупреждения о пользовательских store-dir,
+cache-dir и public-hoist-pattern; эти настройки не
+относятся к П39 и не менялись.
+
+Не запускались strict audit после подключения registry, production build,
+browser, screen reader, CI, deployment или публикация. Static SVG preflight не
+заменяет browser review, accessibility audit и integration test выбранной
+инфраструктуры.
+
+## Итог
+
+Статус автономного этапа: **тройное авторское ревью пройдено; П39 была готова
+к отдельной интеграционной приёмке**. Commit и push на этом этапе намеренно не
+выполнялись.
+
+Созданы ровно пять файлов:
+
+1. web/scripts/upgrade-2021-05.mjs;
+2. editorial/reviews/2021-05-draft.md;
+3. web/public/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg;
+4. web/public/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg;
+5. web/public/assets/editorial/2021/distributed-lock-diagnosis-2021.svg.
+
+## Независимая интеграционная приёмка
+
+Основной редактор 31 июля 2026 года подключил три revision к
+web/data/editorial-revisions.mjs, сохранив базовый
+articles.json, даты и автора архивных записей. В registry стало
+112 revision, строгий аудит проходит 121 из 358 материалов.
+
+Независимый факт-чек уточнил одну формулировку до выпуска: в etcd v3.4
+LeaseGrantRequest.TTL назван advisory, а
+LeaseGrantResponse.TTL — выбранным server значением. Поэтому
+слова «запрошенный TTL — минимум» заменены на точную границу API. Учебные
+5000 мс не стали claim о настройке etcd. Отдельно перепроверены: lifecycle
+lock key при expiry/revoke lease и Chubby-подход с advisory lock/acquisition
+count, который защищаемый server сравнивает от delayed write.
+
+| Проверка после интеграции | Реальный результат |
+| --- | --- |
+| Import-safe export | PASS: три revision, без date/author |
+| Строгий audit трёх slug | PASS: **10 939 / 10 293 / 9 857** знаков; у каждой статьи есть figure, table и code examples |
+| In-memory fixture | PASS: все 9 assertions истинны; token 2 принят, token 1 после него отклонён, старый release не снял lease-B |
+| Независимый SVG review | PASS: XML и active/external asset scan прошли; три PNG 375 px просмотрены повторно, clipping, overlap и overflow не обнаружены |
+| Production build | PASS: Next.js собрал 374 статические страницы |
+
+Ни этот отчёт, ни интеграция не утверждают запуск etcd, другого provider,
+cluster, базы, HTTP, browser, SDK или внешнего API.
+
+Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной
+публикационной операцией; Git остаётся источником её фактической записи.
diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs
index bf44279..9c461cb 100644
--- a/web/data/editorial-revisions.mjs
+++ b/web/data/editorial-revisions.mjs
@@ -35,6 +35,7 @@ import { revisions as january2021Revisions } from '../scripts/upgrade-2021-01.mj
import { revisions as february2021Revisions } from '../scripts/upgrade-2021-02.mjs';
import { revisions as march2021Revisions } from '../scripts/upgrade-2021-03.mjs';
import { revisions as april2021Revisions } from '../scripts/upgrade-2021-04.mjs';
+import { revisions as may2021Revisions } from '../scripts/upgrade-2021-05.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -75,4 +76,5 @@ export const editorialRevisions = [
...february2021Revisions,
...march2021Revisions,
...april2021Revisions,
+ ...may2021Revisions,
];
diff --git a/web/public/assets/editorial/2021/distributed-lock-diagnosis-2021.svg b/web/public/assets/editorial/2021/distributed-lock-diagnosis-2021.svg
new file mode 100644
index 0000000..73035b2
--- /dev/null
+++ b/web/public/assets/editorial/2021/distributed-lock-diagnosis-2021.svg
@@ -0,0 +1,91 @@
+
diff --git a/web/public/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg b/web/public/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg
new file mode 100644
index 0000000..aa19546
--- /dev/null
+++ b/web/public/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg
@@ -0,0 +1,65 @@
+
diff --git a/web/public/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg b/web/public/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg
new file mode 100644
index 0000000..c4271ce
--- /dev/null
+++ b/web/public/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg
@@ -0,0 +1,82 @@
+
diff --git a/web/scripts/upgrade-2021-05.mjs b/web/scripts/upgrade-2021-05.mjs
new file mode 100644
index 0000000..f25c9c1
--- /dev/null
+++ b/web/scripts/upgrade-2021-05.mjs
@@ -0,0 +1,686 @@
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function paragraph(text) {
+ return '' + text + '
'; +} + +function heading(text) { + return '' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '';
+}
+
+function figure(src, alt, caption) {
+ return 'report:417:rebuild и когда истекает lease. Защищаемый ресурс знает, какое значение он уже принял. Если ресурсом является таблица, файл, API партнёра или другой сервис, lock authority не получает автоматического права отклонять его поздние запросы. Эту границу надо назвать до выбора клиента и TTL.'),
+ dataTable(
+ 'Учебный контракт одной критической секции',
+ ['Поле', 'Кто владеет', 'Что проверяем', 'Чего поле не доказывает'],
+ [
+ ['lockName', 'lock authority', 'оба worker спорят за один и тот же предмет', 'что все связанные ресурсы вошли в тот же lock'],
+ ['ownerId', 'клиент/журнал запроса', 'какой worker отправил действие', 'что процесс всё ещё исполняется или имеет право писать'],
+ ['leaseId', 'lock authority', 'release относится к текущему захвату', 'что старый request уже исчез из сети'],
+ ['fenceToken', 'поколение владения и ресурс', 'resource принимает только token больше последнего принятого', 'что token сам по себе отменяет старый request'],
+ ['acceptedFenceToken', 'защищаемый ресурс', 'поздний владелец не перезапишет новое значение', 'что resource получил все запросы или делает exactly-once'],
+ ],
+ ),
+ paragraph('Здесь ownerId нужен для наблюдения, а не для авторизации. leaseId позволяет не снять чужой lock при запоздалом release. Fence token — это монотонное поколение одного защищаемого предмета. В учебной модели это числа 1 и 2. В реальной схеме способ получить такое поколение зависит от выбранного провайдера и ресурса; нельзя произвольно объявить его строковый key или timestamp. Главное требование находится ниже по цепочке: ресурс должен хранить последний принятый token и уметь отклонить меньший или равный.'),
+ heading('Lease даёт окно владения, а не вечное разрешение'),
+ paragraph('Давайте начнём с захвата. В модели worker-A получает lease на 5000 мс и token 1. Число выбрано только для читаемого interleaving. Оно не является рекомендацией TTL: в реальной работе его нельзя выбрать без ограничения операции, сети, keepalive и поведения конкретного провайдера. Мы также не берём системное время. Учебный clock двигается вручную, чтобы порядок событий нельзя было случайно изменить нагрузкой машины.'),
+ codeBlock(acquireCode),
+ paragraph('Официальный API etcd v3.4 полезен как историческая граница: Lock возвращает key на время владения, а lease автоматически освобождает lock при истечении или отзыве. В API lease запрошенный TTL advisory, а ответ возвращает TTL, выбранный server. Из этого не следует, что значение key можно без проверки передать в произвольную базу как fencing token. Документация обещает lifecycle lock внутри etcd, а не правило записи в другой ресурс.'),
+ figure(
+ '/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg',
+ 'Вертикальная временная шкала учебного interleaving: worker-A получает lease-1 и token 1, останавливается после истечения lease, worker-B получает lease-2 и token 2, ресурс принимает запись token 2 и отклоняет позднюю запись token 1',
+ 'Истечение lease освобождает имя для нового владельца, но не отзывает уже вычисленную работу старого процесса.',
+ ),
+ heading('Проверяем interleaving до интеграции с провайдером'),
+ paragraph('Воспроизводимый случай состоит из шести событий. В t=0 worker-A получил lease-1. В t=6000 модель отмечает его истекшим: worker-A не присылал keepalive, но сам объект worker-A не уничтожается. В тот же момент worker-B получает lease-2 с token 2. Он первым меняет ресурс. В t=6200 старый worker-A продолжает ранее начатую работу. Самая важная строка — не выдача второго lease, а ответ ресурса на request с token 1.'),
+ codeBlock(interleavingCode),
+ paragraph('Такой порядок не требует утверждать, что реальные часы точны или что провайдер всегда мгновенно наблюдает остановку клиента. Он фиксирует более простой факт: request может жить дольше представления worker о своём владении. Поэтому проверка lease is active только перед началом работы недостаточна. Между проверкой и write lease может закончиться, другой worker получить новое поколение, а первый request всё равно дойти до ресурса.'),
+ heading('Ресурс принимает только новое поколение'),
+ paragraph('Проверка fencing должна находиться там, где появляется эффект. В модели ресурс хранит acceptedFenceToken. Request с token 2 увеличивает это значение и записывает report-v2-from-worker-B. Следующий request с token 1 не сравнивается с локальным clock worker-A и не делает сетевой запрос к authority. Ресурс видит уже принятый 2 и возвращает rejected-stale-fence. Так поздняя работа становится наблюдаемым отказом, а не тихой порчей значения.'),
+ codeBlock(protectedWriteCode),
+ paragraph('Требование строгого > имеет смысл только для одного доменного предмета и одной линии поколений. Если два независимых действия действительно могут выполняться параллельно, им не стоит навязывать общий token ради удобства. Если одно действие зависит от другого, оба должны указывать на один и тот же ресурсный contract. Важно не число как такое, а то, кто хранит последнее принятое значение и в какой атомарной операции он его сравнивает с request.'),
+ heading('Контраст: lock без fencing оставляет позднюю запись допустимой'),
+ paragraph('Ниже намеренно показан небезопасный вариант. Он не проверяет token вообще. Worker-B сначала записывает v2, затем старый worker-A записывает old-v1. Такой ресурс не знает, что lock уже менял владельца, поэтому обе записи выглядят ему одинаково допустимыми. Наличие lease в другом компоненте не меняет этот результат.'),
+ codeBlock(unsafeWriteCode),
+ paragraph('Этот контраст не говорит, что fencing автоматически подходит к любому ресурсу. В API без условной записи, версии, сравнения или server-side проверки token некуда положить правило. Тогда сначала надо уменьшить критическую секцию до ресурса с контролируемой транзакционной границей либо выбрать другой contract эффекта. Просто увеличить TTL, повторить acquire или написать в лог «lock held» не превращает операцию в безопасную.'),
+ heading('Release тоже принадлежит конкретному захвату'),
+ paragraph('После t=6200 worker-A может попытаться честно освободить то, что он когда-то взял. Если release принимает только имя lock, старый запрос способен удалить lease worker-B. Поэтому release должен сверять owner, leaseId и поколение текущего захвата. В нашей модели старый release получает release-rejected-not-owner; worker-B продолжает владеть lease-2. Это отдельная проверка от fencing: она защищает lock authority, а не запись в ресурс.'),
+ codeBlock(releaseCode),
+ paragraph('Не стоит заменять эту проверку случайной строкой owner. Owner нужен, чтобы оператор видел автора действия. Для release важен неповторимый handle текущего захвата, который выдал provider. Для защищённой записи важен token, который ресурс способен сравнить. Один идентификатор может участвовать в обоих протоколах только если это явно задокументировано и проверено в конкретной интеграции. Учебная модель намеренно не делает такого вывода.'),
+ heading('Маршрут проверки перед критической секцией'),
+ orderedList([
+ 'Назвать один предмет блокировки и один ресурс, который реально меняется. Если список ресурсов разный, не скрывать это за одним широким lockName.',
+ 'Зафиксировать, что provider считает окончанием владения: явный release, истечение lease, отзыв сессии или другой документированный исход.',
+ 'Проверить, какой handle относится к release. Старый worker не должен иметь возможность снять более новый lease.',
+ 'Определить монотонный token для каждого защищаемого предмета. Не выводить его из wall clock и не считать произвольный provider key достаточным без контракта.',
+ 'Добавить в ресурс атомарную проверку: принять request только если token больше последнего принятого, иначе вернуть отдельный stale result.',
+ 'Собрать fixture с паузой владельца после expiry, вторым захватом и поздней записью первого worker. Отдельно показать, что вариант без проверки переписывает значение.',
+ 'Только затем проверять выбранный provider на его версии, TTL, keepalive, сбои и нагрузку. Эти тесты не заменяют ресурсную границу fencing.',
+ ]),
+ heading('Историческая рамка и пределы учебного примера'),
+ paragraph('Для исторической рамки мая 2021 здесь выбран versioned API etcd v3.4. Статья не переносит в него возможности более поздних линий. Документация v3.4 описывает lease и lock key, но не обещает универсальный token для чужой базы или API. Первичная работа Chubby ещё прямее разделяет ответственности: locks advisory, sequencer передаётся получателю, а получатель проверяет его актуальность. Это полезная модель мышления, не инструкция по установке Chubby.'),
+ paragraph('В пакете не запускались etcd, Redis, ZooKeeper, база, HTTP, provider SDK, cluster, keepalive, реальные часы, синхронизация времени, нагрузочный тест, browser, CI, production build или deployment. Нет claim о provider SLA, clock correctness, exactly-once или работе на реальных данных. Следующий шаг после статьи — выбрать один фактический ресурс и доказать его условную запись в отдельном интеграционном тесте. Если ресурс не умеет отвергать старый token, lease следует считать координацией, а не защитой от позднего эффекта.'),
+ ],
+ [etcd34Lock, etcd34Lease, chubbyPaper],
+);
+
+const mechanismArticle = createRevision(
+ {
+ slug: 'editorial-2021-05-mechanism-distributed-locks',
+ title: 'Fence token: почему lock не защищает поздний запрос сам по себе',
+ categories: ['Backend', 'Архитектура', 'Надёжность'],
+ cover: '/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg',
+ excerpt: 'После истечения lease старый процесс может закончить работу. Fence token полезен только тогда, когда защищаемый ресурс хранит последнее поколение и отклоняет меньший token в своей операции записи.',
+ readingMinutes: 16,
+ },
+ [
+ paragraph('Симптом сложнее обычного duplicate: новый worker уже записал верное значение, но через несколько секунд приходит запрос от старого владельца lease и возвращает 200. Цена — не только испорченные данные. После такого ответа команда может решить, что lock provider выдал два владения одновременно, хотя реальная ошибка находится в другом месте: ресурс вообще не знал о поколении lock.'),
+ paragraph('Fence token нужен именно для этой границы. Он не останавливает старый worker и не удлиняет lease. Это монотонное число или другая сравнимая версия, которую request несёт к защищаемому ресурсу. Ресурс принимает новое поколение и запоминает его; меньший token отклоняет. ' + trainingNotice + ' Поэтому пример ниже не делает вывод о реализации конкретного provider и не выдаёт Map за distributed lock.'),
+ heading('У lock authority и ресурса разные вопросы'),
+ paragraph('Lock authority отвечает на вопрос: «кому сейчас можно выдать следующее владение именем?». Ресурс отвечает на другой: «можно ли этому request изменить моё состояние после уже принятого поколения?». Первый вопрос обычно решается lease и ожиданием. Второй — условной записью, compare-and-set, транзакцией с версией или явной проверкой в server-side обработчике. Если второму вопросу негде жить, token остаётся только полем в логе.'),
+ dataTable(
+ 'Граница ответственности для token 1 и token 2',
+ ['Событие', 'Что знает authority', 'Что знает ресурс', 'Безопасный исход'],
+ [
+ ['worker-A получил token 1', 'активен lease-1', 'ещё не видел write', 'ожидать или принять token 1'],
+ ['lease-1 истёк', 'имя снова доступно', 'может всё ещё ждать request от A', 'не делать вывод, что A остановлен'],
+ ['worker-B получил token 2', 'активен lease-2', 'ещё не обязан знать о B', 'передать token 2 вместе с write'],
+ ['ресурс принял token 2', 'может не участвовать в write', 'highest token равен 2', 'сохранить v2'],
+ ['поздний request с token 1', 'может уже видеть lease-2', 'highest token равен 2', 'отклонить stale request'],
+ ],
+ ),
+ paragraph('Последняя строка важна: ресурс не обязан спрашивать lock authority, активен ли у A lease. Такой запрос мог бы добавить ещё одну гонку и зависимость. Ему достаточно собственного монотонного факта: token 2 уже принят для этого предмета. Это не доказывает, что worker-B выполнил весь бизнес-процесс, но предотвращает конкретный поздний write от token 1. Цель fencing всегда должна быть названа так узко.'),
+ heading('Что именно делает ресурсная проверка'),
+ paragraph('В учебной функции состояние ресурса содержит acceptedFenceToken. Перед записью сравниваем request с этим состоянием. В настоящем SQL это может быть условие в одном UPDATE; в объектном storage — версия при записи; в сервисе — проверка и хранение token внутри его транзакционной границы. Форма меняется, инвариант нет: чтение последнего token и фиксация нового значения не должны разъехаться между двумя независимыми действиями.'),
+ codeBlock(protectedWriteCode),
+ paragraph('Проверка не должна принимать равный token как новый. Равенство часто означает повторный request того же владельца. Можно отдельно проектировать идемпотентный ответ для одинакового operation id, но это другой contract. В минимальной модели token служит только для сравнения поколений, поэтому <= отвергается. Не нужно смешивать этот результат с answer про delivery, повтор запроса или запись бизнес-эффекта.'),
+ figure(
+ '/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg',
+ 'Вертикальная схема границы fencing: lock authority выдаёт worker-A token 1, после истечения lease выдаёт worker-B token 2; защищаемый ресурс принимает token 2 и отклоняет запоздалый request с token 1 по своему highest accepted token',
+ 'Authority выдаёт поколения владения; обязательная защита появляется только в ресурсе, который сравнивает token со своим сохранённым состоянием.',
+ ),
+ heading('Почему нельзя проверить lock только в начале'),
+ paragraph('Частая последовательность выглядит разумно: acquire, проверить lease, сделать дорогую работу, записать результат. Но проверка в начале относится к моменту до работы. Если процесс остановился на GC, в ожидании базы, в debugger или на медленном ответе, его локальная память всё ещё содержит успешный acquire. После истечения lease authority может законно выдать новое владение. Когда старый worker проснётся, его успешная проверка из прошлого ничего не говорит о праве на текущий write.'),
+ codeBlock(interleavingCode),
+ paragraph('Продление lease меняет только длину окна и добавляет свой protocol: кто и когда подтвердил keepalive, что считать неуспехом, сколько продлений допустимо. Оно не отменяет необходимость рассуждать о request, который уже ушёл или уже вычислен. Поэтому в критичной операции сначала нужен ресурсный fence. После этого можно обсуждать продление как способ уменьшить лишнюю работу, но не как доказательство того, что процесс никогда не станет stale.'),
+ heading('Исторический пример не превращает key в token'),
+ paragraph('В versioned документации etcd v3.4 Lock возвращает уникальный key, существующий пока caller владеет lock; expiry lease освобождает lock. Это полезная семантика ownership в самом etcd. Но API не говорит: «возьмите этот key, и внешняя PostgreSQL, файловый сервис или партнёрский API автоматически начнёт отвергать старые запросы». Поэтому нельзя называть любой id fencing token только потому, что он уникален. Нужны порядок поколений и ресурсная проверка этого порядка.'),
+ paragraph('Работа Chubby 2006 описывает похожее разделение языком sequencer. Locks там advisory: они конфликтуют с захватом того же lock, но сами не делают произвольный файл или сервис недоступным. Клиент передаёт sequencer серверу, который ожидаемо проверяет его актуальность и нужный режим; при невалидности запрос отклоняется. Это не означает, что каждый provider даёт готовый sequencer, зато показывает, почему обязательство находится у получателя write.'),
+ heading('Owner, token и idempotency не взаимозаменяемы'),
+ paragraph('Owner отвечает на вопрос «кто отправил запрос». Fence token отвечает «это поколение новее последнего принятого?». Idempotency key отвечает «выполняли ли уже именно эту доменную операцию?». Иногда одно сообщение несёт все три поля, но проверять их нужно по разным правилам. Замена их одним requestId делает журнал удобнее, а поведение — неяснее: старый request может иметь уникальный id, но всё равно не иметь права перезаписывать новый результат.'),
+ dataTable(
+ 'Три поля для трёх разных проверок',
+ ['Поле', 'Сравнение', 'Пример отказа', 'Не заменяет'],
+ [
+ ['ownerId', 'с текущим handle при release', 'старый A пытается снять lease-B', 'resource-side fence'],
+ ['fenceToken', 'строго больше highest token ресурса', 'token 1 приходит после принятого token 2', 'идемпотентность одного effect'],
+ ['operationId', 'с ledger операции', 'повтор delivery того же business action', 'порядок поколений владельца'],
+ ['leaseId', 'точное равенство текущему lease', 'release старого handle', 'право на write после expiry'],
+ ],
+ ),
+ paragraph('Эта таблица не требует строить большую платформу. Даже в одном сервисе она помогает не склеить диагностику: stale fence — это не duplicate delivery, а повторный operation id — не сигнал, что можно разрешить token 1. В начале достаточно договориться, какое из состояний хранится рядом с ресурсом и что вернёт API при нарушении каждого правила.'),
+ heading('Небезопасный контраст полезнее общего обещания'),
+ paragraph('Если ресурс всегда присваивает поле без условия, оба worker смогут получить успешный ответ. Внизу видно, как token 2 записывает v2, а token 1 затем записывает old-v1. Это не ошибка модели: именно так выглядит внешний ресурс, который не участвует в fencing. Нельзя исправить это задним числом логом о том, что A «раньше был владельцем».'),
+ codeBlock(unsafeWriteCode),
+ paragraph('Если ресурс не предоставляет условную запись, надо прямо записать ограничение. Для некоторых задач можно перенести эффект в транзакционную базу, использовать механизм versioning этого ресурса, отказаться от автоматического действия или вынести его на ручную проверку. Выбор зависит от предмета. Важнее не обещать, что строка acquire перед функцией сама защищает действие, которое провайдер не видит.'),
+ heading('Как проверить boundary в своём коде'),
+ orderedList([
+ 'Найти финальный write, который действительно создаёт риск: изменение строки, отправку команды, создание файла или вызов внешнего API.',
+ 'Определить owner и точный handle текущего lease для acquire/release; не использовать старый handle после reconnect как доказательство владения.',
+ 'Выбрать монотонное поколение для одного resource key. Документировать, откуда оно берётся и какие значения ресурс считает устаревшими.',
+ 'Сделать compare token и запись значения одной ресурсной операцией. Отдельное чтение highest token перед write оставляет новую гонку.',
+ 'Вернуть различимый результат stale fence. Не маскировать его под timeout или успешное выполнение.',
+ 'Добавить учебный или интеграционный interleaving: token 1 pause, expiry, token 2 accepted, поздний token 1 rejected.',
+ 'Отдельно проверить idempotency и retry, если действие может быть доставлено повторно. Они дополняют fence, но не возникают из него автоматически.',
+ ]),
+ heading('Пределы утверждений'),
+ paragraph('Модель доказывает только девять детерминированных assertions в памяти, включая отклонение token 1 после token 2 и отказ старого release. Она не измеряет latency, не проверяет clock drift, не запускает cluster и не говорит, что etcd v3.4 или другой provider даст нужную последовательность token для вашей базы. Источник v3.4 выбран как доступная к маю 2021 историческая линия; его API надо читать вместе с конкретным client и deployment contract.'),
+ paragraph('В пакете не было реального provider, базы, внешнего resource, HTTP, broker, сети, синхронизации времени, нагрузки, browser, CI, build, deployment или production данных. Следующий практический шаг — не увеличить lease, а показать на выбранном ресурсе атомарный reject старого поколения. Пока этого reject нет, lock полезен для координации работы, но не является доказательством безопасности позднего запроса.'),
+ ],
+ [etcd34Lock, etcd34Lease, chubbyPaper],
+);
+
+const fieldArticle = createRevision(
+ {
+ slug: 'editorial-2021-05-field-distributed-locks',
+ title: 'Поздний владелец lease: как разобрать stale write без ложного диагноза',
+ categories: ['Отладка', 'Надёжность', 'Практика'],
+ cover: '/assets/editorial/2021/distributed-lock-diagnosis-2021.svg',
+ excerpt: 'Когда старый worker пишет после expiry, сначала собираем leaseId, token и highest token ресурса. Такой пакет фактов отделяет stale write от duplicate, неверного release и предположений о времени.',
+ readingMinutes: 16,
+ },
+ [
+ paragraph('Симптом в журнале часто выглядит противоречиво: worker-B успешно записал новый результат, а потом worker-A сообщил об успешной обработке того же предмета. Цена поспешного диагноза высока. Можно обвинить provider в «двойном lock», увеличить TTL и пропустить факт, что ресурс принял поздний request без проверки поколения. После следующей паузы ошибка повторится, но лог будет содержать ещё больше лишних объяснений.'),
+ paragraph('Для разбора нужен небольшой пакет доказательств, а не догадка о точности часов. Сохраняем lockName, ownerId, leaseId, fenceToken, порядок учебных событий, highest token ресурса до и после write и результат проверки. Этого достаточно, чтобы отличить stale owner от duplicate операции или старого release. ' + trainingNotice),
+ heading('Собираем факт до повторного запуска'),
+ paragraph('Первое действие при подозрении на stale write — не делать automatic retry. Если request с token 1 уже пришёл после принятого token 2, повтор token 1 не станет новее. Он должен получить тот же явный отказ. Нужна запись, которая связывает один request с одним поколением и состоянием ресурса в момент решения. В учебном примере это обычный объект; в рабочем коде состав полей зависит от политики данных и от того, где живёт ресурсная транзакция.'),
+ codeBlock(evidenceCode),
+ paragraph('Поле resourceHighestTokenBefore особенно полезно. Если оно уже равно 2, а request несёт 1, причина отказа читается без предположения «worker-A точно спал шесть секунд». Мы видим, что ресурс уже принял более новое поколение. Если highest token ещё 0, ситуация другая: возможно, новый owner не успел записать, либо request ушёл к другому resource key. Эти ветви нельзя склеивать в одну метку lock-error.'),
+ dataTable(
+ 'Диагностика позднего write',
+ ['Наблюдение', 'Вероятная граница', 'Что проверить', 'Следующее действие'],
+ [
+ ['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'],
+ ],
+ ),
+ paragraph('Таблица намеренно не содержит строку «синхронизировать часы и закрыть задачу». Время помогает восстановить порядок, но fence не должен полагаться на timestamp worker. При записи значение token сравнивается внутри ресурса. Если в диагностике есть только wall-clock логи, а resource не записывает принятую версию, вы не сможете доказать, был ли поздний request опасен или просто пришёл после другого несвязанного события.'),
+ heading('Проверьте конкретный interleaving, а не красивую схему'),
+ paragraph('В 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.'),
+ codeBlock(fixtureCode),
+ paragraph('Такой тест не проверяет фактический provider. Он проверяет, что команда не потеряла нужный сценарий в обсуждении. Если кто-то заменит сравнение <= на безусловный write, assertion protectedResourceRetainedNewerValue перестанет выполняться. Если убрать resource check, unsafe contrast покажет старое значение в финале. Это хороший маленький барьер перед интеграционным тестом, но не замена ему.'),
+ figure(
+ '/assets/editorial/2021/distributed-lock-diagnosis-2021.svg',
+ 'Вертикальное дерево диагностики: сначала фиксируется resource key и token, затем различаются stale token после более нового принятого token, повтор operation id, неверный release и ресурс без fencing; каждая ветка заканчивается конкретной проверкой или действием',
+ 'Диагностика начинается с состояния ресурса: без highest token нельзя отличить позднего владельца от другой причины повторной записи.',
+ ),
+ heading('Не путайте expiry с остановкой процесса'),
+ paragraph('Истечение lease означает только, что authority больше не считает этот захват активным. Оно не завершает функцию в worker-A, не отзывает переменную leaseId из памяти и не гарантирует отмену уже отправленного request. Это особенно заметно, если дорогое вычисление построило payload до паузы, а HTTP-клиент продолжил отправку после неё. Поэтому фраза «lease истёк, значит A уже ничего не сделает» не должна попадать в runbook.'),
+ paragraph('Документация etcd v3.4 формулирует это со стороны lock: при expiry lease относящиеся к нему keys удаляются и lock освобождается. Она не описывает отмену работы клиента и не добавляет проверку к внешнему write. Исторический Chubby paper отдельно называет locks advisory и описывает sequencer, который получатель запроса должен проверить. Эти два источника полезны именно потому, что не скрывают границу в удобной фразе «взяли блокировку».'),
+ heading('Старый release — другой симптом'),
+ paragraph('Иногда stale write уже защищён fence, но после него lock неожиданно свободен. Тогда смотрим не на token ресурса, а на release. Worker-A мог сохранить handle старого захвата и после pause послать delete по одному lockName. Если authority принимает такую операцию, A способен снять lease-2 worker-B. Это не отменяет fencing у ресурса, но создаёт новую конкуренцию для последующих worker.'),
+ codeBlock(releaseCode),
+ paragraph('В учебной модели release содержит owner, leaseId и token. Модель не даёт старому A удалить активный B и фиксирует release-rejected-not-owner. Конкретный provider может использовать другой handle и другое API; не переносите поля буквально. Инвариант остаётся: release должен быть условным по идентификатору того владения, которое будет освобождено, а не только по имени предмета.'),
+ heading('Где fence заканчивается'),
+ paragraph('Fence предотвращает старое поколение write для того ресурса, который его проверяет. Он не делает весь workflow exactly-once, не отменяет письмо, уже принятое внешним API, и не определяет порядок между разными resource keys. Если операция создаёт несколько эффектов, у каждого должен быть свой контракт: один ресурс может держать token, другой — operation id, третий — явное ручное решение. Одна блокировка вокруг всего процесса не заменяет эту работу.'),
+ dataTable(
+ 'Что 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 как отдельная локальная дисциплина'],
+ ],
+ ),
+ paragraph('Эта граница не делает lock бесполезным. Lease полезен, чтобы не запускать одну дорогую работу одновременно, а fence полезен, чтобы поздняя работа не стала новым состоянием там, где ресурс умеет сравнение. Ошибка начинается, когда один механизм получает чужое обещание. Особенно опасно обобщение «мы используем distributed lock, значит транзакция защищена»: оно скрывает, какой именно write ресурс обязан отклонять.'),
+ heading('Маршрут полевого разбора'),
+ orderedList([
+ 'Остановить только автоматический 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 или интеграционный тест.',
+ ]),
+ heading('Что можно утверждать после такой проверки'),
+ paragraph('После успешно пройденного сценария можно сказать узко: ресурс отклонил учебный поздний request token 1 после принятого token 2, а старый handle не освободил новый lease. Нельзя сказать, что provider безопасен при любой сети, часы корректны, кластер выдержал partition или бизнес-эффект exactly-once. Честный результат меньше по масштабу, зато даёт проверяемую границу следующему изменению.'),
+ paragraph('Здесь не запускались реальные etcd/Redis/ZooKeeper, база, provider SDK, сеть, синхронизация времени, cluster, external API, browser, CI, production build, deployment или нагрузка. Нет настоящих пользовательских данных и claim о SLA. Следующий шаг — воспроизвести тот же порядок против конкретного ресурса: зафиксировать способ выдать упорядоченный token, доказать атомарное сравнение на write и проверить поведение старого release. Без этой тройки лог о lock остаётся наблюдением, но не гарантией.'),
+ ],
+ [etcd34Lock, etcd34Lease, chubbyPaper],
+);
+
+export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
+
+if (process.argv.includes('--print-revisions')) {
+ process.stdout.write(JSON.stringify(revisions));
+} else if (process.argv.includes('--verify-fixture')) {
+ process.stdout.write(JSON.stringify(runDistributedLockFixture(), null, 2) + '\n');
+}