revise May 2021 distributed lock articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 12:37:36 +03:00
parent c561263bfc
commit 32e0550f4f
7 changed files with 1101 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 118 из 358 созданных материалов. Остальные 240 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 121 из 358 созданных материалов. Остальные 237 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+174
View File
@@ -0,0 +1,174 @@
# Автономное тройное ревью П39 · май 2021 · «Блокировки и конкуренция»
Статус: **авторское тройное ревью пройдено, затем пакет принят независимым
редактором в выпусковой набор**. Revision-модуль содержит ровно три
стабильных slug:
- <code>editorial-2021-05-practice-distributed-locks</code>;
- <code>editorial-2021-05-mechanism-distributed-locks</code>;
- <code>editorial-2021-05-field-distributed-locks</code>.
В <code>revisions</code> нет <code>date</code>, <code>author</code> или
подключения registry. Эта авторская партия не меняла
<code>articles.json</code>, README, очередь, стандарт, package config или Git.
Созданы только пять разрешённых файлов, перечисленных внизу документа.
## Проход 1. Факты, историческая рамка и техника — пройдено
| Утверждение или решение | Первичный / официальный источник | Зафиксированная граница |
| --- | --- | --- |
| Для исторической точки мая 2021 выбрана линия <code>etcd v3.4</code>: её <code>Lock</code> возвращает 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. |
| <code>LeaseGrant</code> получает 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. |
<code>runDistributedLockFixture()</code> — детерминированная state machine
только на <code>Map</code> и ручном счётчике времени. В ней нет системных
часов, сети или provider client:
1. worker-A получает <code>training-lease-1</code> и token 1 на 5000 мс;
2. учебный clock переходит к 6000 мс, lease-1 помечается истёкшим, но
worker-A не считается остановленным;
3. worker-B получает <code>training-lease-2</code> и token 2;
4. защищаемый ресурс принимает write worker-B, сохраняет v2 и highest token 2;
5. поздний write worker-A с token 1 получает
<code>rejected-stale-fence</code>;
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 с <code>caption</code>/<code>thead</code>,
figure с самостоятельными <code>alt</code>/<code>figcaption</code>, не
менее двух технических примеров, нумерованный путь, три первичных или
официальных источника и явный тестовый interleaving.
- Голос М4 / мая 2021 продолжает пакеты о транзакции, кешировании и очередях:
автор называет owner, handle, ресурсную границу и инвариант, но не
изображает себя владельцем большой distributed platform. Речь короткая и
предметная: <code>token 1 ≤ highest token 2 → reject</code>, а не
«блокировка магически защищает данные».
- Убраны опасные подмены: 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 — пройдено в границах пакета
- <code>distributed-lock-lease-timeline-2021.svg</code> показывает полный
interleaving от lease-1/token 1 до reject позднего token 1. После первого
Sharp-рендера длинная нижняя подпись была разбита на две строки.
- <code>distributed-lock-fence-boundary-2021.svg</code> отделяет authority от
ресурса и показывает, где именно хранится highest token. После первого
рендера нижняя карточка получила две короткие строки и большую высоту.
- <code>distributed-lock-diagnosis-2021.svg</code> ведёт от evidence packet к
stale generation, duplicate, resource without compare и bad release. После
первого рендера перенесены длинная итоговая подпись и строка atomic compare.
- У всех трёх SVG есть <code>title</code>, <code>desc</code>,
<code>role="img"</code> и вертикальный viewBox. В исходниках нет
JavaScript, <code>foreignObject</code>, external URL или data URI; обычный
XML namespace не является внешним asset.
### Фактически выполненные проверки
Команды запускались из <code>web/</code> после финальных правок:
<pre><code>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</code></pre>
| Проверка | Реальный результат |
| --- | --- |
| <code>node --check</code> | 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 |
| <code>xmllint --noout</code> | PASS, все три SVG — корректный XML |
| SVG safety scan | PASS: нет <code>script</code>, <code>foreignObject</code>, 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, <code>articles.json</code>, README, очередь, стандарт, package config, Git и чужие untracked files не менялись |
<code>npm run audit:draft</code> завершилась с code 0. npm вывел уже
существующие предупреждения о пользовательских <code>store-dir</code>,
<code>cache-dir</code> и <code>public-hoist-pattern</code>; эти настройки не
относятся к П39 и не менялись.
Не запускались strict audit после подключения registry, production build,
browser, screen reader, CI, deployment или публикация. Static SVG preflight не
заменяет browser review, accessibility audit и integration test выбранной
инфраструктуры.
## Итог
Статус автономного этапа: **тройное авторское ревью пройдено; П39 была готова
к отдельной интеграционной приёмке**. Commit и push на этом этапе намеренно не
выполнялись.
Созданы ровно пять файлов:
1. <code>web/scripts/upgrade-2021-05.mjs</code>;
2. <code>editorial/reviews/2021-05-draft.md</code>;
3. <code>web/public/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg</code>;
4. <code>web/public/assets/editorial/2021/distributed-lock-fence-boundary-2021.svg</code>;
5. <code>web/public/assets/editorial/2021/distributed-lock-diagnosis-2021.svg</code>.
## Независимая интеграционная приёмка
Основной редактор 31 июля 2026 года подключил три revision к
<code>web/data/editorial-revisions.mjs</code>, сохранив базовый
<code>articles.json</code>, даты и автора архивных записей. В registry стало
112 revision, строгий аудит проходит 121 из 358 материалов.
Независимый факт-чек уточнил одну формулировку до выпуска: в etcd v3.4
<code>LeaseGrantRequest.TTL</code> назван advisory, а
<code>LeaseGrantResponse.TTL</code> — выбранным 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, без <code>date</code>/<code>author</code> |
| Строгий 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 остаётся источником её фактической записи.
+2
View File
@@ -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 february2021Revisions } from '../scripts/upgrade-2021-02.mjs';
import { revisions as march2021Revisions } from '../scripts/upgrade-2021-03.mjs'; import { revisions as march2021Revisions } from '../scripts/upgrade-2021-03.mjs';
import { revisions as april2021Revisions } from '../scripts/upgrade-2021-04.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. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -75,4 +76,5 @@ export const editorialRevisions = [
...february2021Revisions, ...february2021Revisions,
...march2021Revisions, ...march2021Revisions,
...april2021Revisions, ...april2021Revisions,
...may2021Revisions,
]; ];
@@ -0,0 +1,91 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1620" role="img" aria-labelledby="title desc">
<title id="title">Диагностика позднего владельца lease</title>
<desc id="desc">Вертикальное дерево разбора поздней записи. Сначала собираются resource key и fencing token, затем различаются устаревшее поколение, повтор операции, ресурс без fencing и неверный release.</desc>
<defs>
<marker id="arrow" viewBox="0 0 12 12" refX="10" refY="6" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
<path d="M1 1 L11 6 L1 11z" fill="#9fb7d7"/>
</marker>
<style>
.bg { fill: #0b1324; }
.card { fill: #14213a; stroke: #42658f; stroke-width: 3; }
.decision { fill: #173e45; stroke: #48c5be; stroke-width: 3; }
.ok { fill: #1a4136; stroke: #67d5a6; stroke-width: 3; }
.warn { fill: #422a30; stroke: #ff9a80; stroke-width: 3; }
.line { stroke: #9fb7d7; stroke-width: 5; fill: none; marker-end: url(#arrow); }
.branch { stroke: #ffb270; stroke-width: 5; fill: none; marker-end: url(#arrow); }
.title { fill: #ffffff; font: 700 34px Arial, sans-serif; }
.label { fill: #eaf2ff; font: 600 27px Arial, sans-serif; }
.body { fill: #d2def2; font: 24px Arial, sans-serif; }
.small { fill: #b6c7e3; font: 22px Arial, sans-serif; }
.tag { fill: #87e5db; font: 700 21px Arial, sans-serif; letter-spacing: 1px; }
.yes { fill: #93e5ba; font: 700 22px Arial, sans-serif; }
.no { fill: #ffcb99; font: 700 22px Arial, sans-serif; }
</style>
</defs>
<rect class="bg" width="720" height="1620" rx="32"/>
<text x="360" y="62" text-anchor="middle" class="title">Разбор поздней записи</text>
<text x="360" y="96" text-anchor="middle" class="small">сначала факт ресурса, затем причина</text>
<rect x="80" y="140" width="560" height="160" rx="22" class="card"/>
<text x="115" y="183" class="tag">1 · СОБРАТЬ EVIDENCE</text>
<text x="115" y="225" class="label">resource key · leaseId · token</text>
<text x="115" y="258" class="body">highest token до и после write</text>
<path d="M360 300 L360 352" class="line"/>
<rect x="80" y="352" width="560" height="156" rx="22" class="decision"/>
<text x="115" y="394" class="tag">2 · СРАВНИТЬ</text>
<text x="115" y="436" class="label">token меньше highest token?</text>
<text x="115" y="469" class="body">это один и тот же resource key?</text>
<path d="M222 508 L222 565" class="line"/>
<path d="M498 508 L498 565" class="branch"/>
<text x="166" y="550" class="yes">ДА</text>
<text x="470" y="550" class="no">НЕТ</text>
<rect x="52" y="585" width="340" height="188" rx="22" class="ok"/>
<text x="82" y="626" class="tag">STALE GENERATION</text>
<text x="82" y="668" class="label">resource уже принял</text>
<text x="82" y="701" class="body">более новый token</text>
<text x="82" y="734" class="small">reject stale write; не retry</text>
<rect x="428" y="585" width="240" height="188" rx="22" class="decision"/>
<text x="458" y="626" class="tag">ДАЛЬШЕ</text>
<text x="458" y="668" class="label">проверить</text>
<text x="458" y="701" class="body">operationId</text>
<text x="458" y="734" class="small">и оба write</text>
<path d="M548 773 L548 830" class="branch"/>
<rect x="428" y="850" width="240" height="176" rx="22" class="card"/>
<text x="458" y="891" class="tag">DUPLICATE?</text>
<text x="458" y="933" class="label">тот же effect</text>
<text x="458" y="966" class="body">с тем же id?</text>
<text x="458" y="999" class="small">да → ledger policy</text>
<path d="M548 1026 L548 1083" class="branch"/>
<rect x="428" y="1103" width="240" height="176" rx="22" class="warn"/>
<text x="458" y="1144" class="tag">BOTH ACCEPTED</text>
<text x="458" y="1186" class="label">ресурс пишет</text>
<text x="458" y="1219" class="body">без compare</text>
<text x="548" y="1252" text-anchor="middle" class="small">atomic compare + fence</text>
<path d="M222 773 L222 830" class="line"/>
<rect x="52" y="850" width="340" height="176" rx="22" class="card"/>
<text x="82" y="891" class="tag">RELEASE CHECK</text>
<text x="82" y="933" class="label">старый handle снимает</text>
<text x="82" y="966" class="body">lease нового worker?</text>
<text x="82" y="999" class="small">сверить exact lease handle</text>
<path d="M222 1026 L222 1083" class="line"/>
<rect x="52" y="1103" width="340" height="176" rx="22" class="decision"/>
<text x="82" y="1144" class="tag">SCOPE CHECK</text>
<text x="82" y="1186" class="label">один lockName, но</text>
<text x="82" y="1219" class="body">разные resource key?</text>
<text x="82" y="1252" class="small">пересмотреть границу token</text>
<path d="M360 1279 L360 1336" class="line"/>
<rect x="80" y="1356" width="560" height="172" rx="22" class="ok"/>
<text x="115" y="1397" class="tag">ИТОГ</text>
<text x="115" y="1439" class="label">Назвать конкретный stale result</text>
<text x="115" y="1472" class="label">и следующий test</text>
<text x="115" y="1505" class="small">Lock log без resource evidence не является гарантией.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1500" role="img" aria-labelledby="title desc">
<title id="title">Граница fencing token между lock authority и ресурсом</title>
<desc id="desc">Вертикальная схема показывает, что lock authority выдаёт lease и поколения владения, а защищаемый ресурс хранит наибольший принятый token. Token два принимается, а поздний token один отклоняется внутри ресурса.</desc>
<defs>
<marker id="arrow" viewBox="0 0 12 12" refX="10" refY="6" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
<path d="M1 1 L11 6 L1 11z" fill="#9fb7d7"/>
</marker>
<style>
.bg { fill: #0b1324; }
.authority { fill: #173e45; stroke: #48c5be; stroke-width: 3; }
.worker { fill: #14213a; stroke: #42658f; stroke-width: 3; }
.resource { fill: #1a4136; stroke: #67d5a6; stroke-width: 3; }
.warn { fill: #422a30; stroke: #ff9a80; stroke-width: 3; }
.line { stroke: #9fb7d7; stroke-width: 5; fill: none; marker-end: url(#arrow); }
.warnline { stroke: #ff9a80; stroke-width: 5; fill: none; marker-end: url(#arrow); }
.title { fill: #ffffff; font: 700 34px Arial, sans-serif; }
.label { fill: #eaf2ff; font: 600 27px Arial, sans-serif; }
.body { fill: #d2def2; font: 24px Arial, sans-serif; }
.small { fill: #b6c7e3; font: 22px Arial, sans-serif; }
.tag { fill: #87e5db; font: 700 21px Arial, sans-serif; letter-spacing: 1px; }
.badtag { fill: #ffbfaa; font: 700 21px Arial, sans-serif; letter-spacing: 1px; }
</style>
</defs>
<rect class="bg" width="720" height="1500" rx="32"/>
<text x="360" y="62" text-anchor="middle" class="title">Fence живёт в ресурсе</text>
<text x="360" y="96" text-anchor="middle" class="small">lease и write отвечают на разные вопросы</text>
<rect x="72" y="142" width="576" height="173" rx="22" class="authority"/>
<text x="108" y="185" class="tag">LOCK AUTHORITY</text>
<text x="108" y="227" class="label">выдаёт lease-1 / token 1</text>
<text x="108" y="260" class="body">знает lifecycle текущего lock</text>
<text x="108" y="292" class="small">не меняет защищаемый ресурс</text>
<path d="M360 315 L360 367" class="line"/>
<rect x="72" y="367" width="576" height="161" rx="22" class="worker"/>
<text x="108" y="409" class="tag">WORKER-A</text>
<text x="108" y="451" class="label">пауза дольше lease</text>
<text x="108" y="484" class="body">старый payload и token 1 остаются</text>
<path d="M360 528 L360 580" class="line"/>
<rect x="72" y="580" width="576" height="174" rx="22" class="authority"/>
<text x="108" y="622" class="tag">LOCK AUTHORITY AFTER EXPIRY</text>
<text x="108" y="664" class="label">выдаёт lease-2 / token 2</text>
<text x="108" y="697" class="body">worker-B становится новым владельцем</text>
<text x="108" y="729" class="small">expiry не отзывает request от worker-A</text>
<path d="M360 754 L360 806" class="line"/>
<rect x="72" y="806" width="576" height="178" rx="22" class="resource"/>
<text x="108" y="848" class="tag">PROTECTED RESOURCE</text>
<text x="108" y="890" class="label">принимает write с token 2</text>
<text x="108" y="923" class="body">сохраняет value v2 и highest token 2</text>
<text x="108" y="956" class="small">сравнение и write — одна граница</text>
<path d="M360 984 L360 1036" class="warnline"/>
<rect x="72" y="1036" width="576" height="179" rx="22" class="warn"/>
<text x="108" y="1078" class="badtag">LATE REQUEST FROM WORKER-A</text>
<text x="108" y="1120" class="label">token 1 ≤ highest token 2</text>
<text x="108" y="1153" class="body">resource returns rejected-stale-fence</text>
<text x="108" y="1186" class="small">authority не обязан участвовать в этом write</text>
<rect x="72" y="1270" width="576" height="150" rx="22" fill="#14213a" stroke="#42658f" stroke-width="3"/>
<text x="360" y="1312" text-anchor="middle" class="label">Без проверки в ресурсе</text>
<text x="360" y="1348" text-anchor="middle" class="label">поздний v1 может перезаписать v2</text>
<text x="360" y="1384" text-anchor="middle" class="small">Сам lock не доказывает безопасность внешнего эффекта.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1420" role="img" aria-labelledby="title desc">
<title id="title">Истечение lease и поздняя запись старого worker</title>
<desc id="desc">Вертикальная временная шкала учебного сценария: worker-A получает lease один и token один, останавливается после истечения lease, worker-B получает lease два и token два, ресурс принимает token два и отвергает поздний token один.</desc>
<defs>
<marker id="arrow" viewBox="0 0 12 12" refX="10" refY="6" markerWidth="10" markerHeight="10" orient="auto-start-reverse">
<path d="M1 1 L11 6 L1 11z" fill="#9fb7d7"/>
</marker>
<style>
.bg { fill: #0b1324; }
.card { fill: #14213a; stroke: #42658f; stroke-width: 3; }
.accent { fill: #173e45; stroke: #48c5be; stroke-width: 3; }
.ok { fill: #1a4136; stroke: #67d5a6; stroke-width: 3; }
.warn { fill: #422a30; stroke: #ff9a80; stroke-width: 3; }
.muted { fill: #2a3040; stroke: #71819d; stroke-width: 3; }
.line { stroke: #9fb7d7; stroke-width: 5; fill: none; marker-end: url(#arrow); }
.dash { stroke: #71819d; stroke-width: 4; fill: none; stroke-dasharray: 10 10; }
.title { fill: #ffffff; font: 700 34px Arial, sans-serif; }
.label { fill: #eaf2ff; font: 600 27px Arial, sans-serif; }
.body { fill: #d2def2; font: 24px Arial, sans-serif; }
.small { fill: #b6c7e3; font: 22px Arial, sans-serif; }
.tag { fill: #87e5db; font: 700 21px Arial, sans-serif; letter-spacing: 1px; }
.time { fill: #ffcf8b; font: 700 24px Arial, sans-serif; }
</style>
</defs>
<rect class="bg" width="720" height="1420" rx="32"/>
<text x="360" y="62" text-anchor="middle" class="title">Lease не отменяет старый request</text>
<text x="360" y="96" text-anchor="middle" class="small">учебный interleaving без реального provider</text>
<path d="M118 160 L118 1300" class="dash"/>
<text x="118" y="151" text-anchor="middle" class="small">время</text>
<text x="42" y="210" class="time">t=0</text>
<circle cx="118" cy="201" r="13" fill="#48c5be"/>
<rect x="166" y="145" width="500" height="142" rx="22" class="accent"/>
<text x="198" y="186" class="tag">WORKER-A · ACQUIRE</text>
<text x="198" y="228" class="label">lease-1, fence token 1</text>
<text x="198" y="261" class="body">критическая работа начинается</text>
<path d="M416 287 L416 339" class="line"/>
<text x="25" y="386" class="time">t=5000</text>
<circle cx="118" cy="378" r="13" fill="#ffb270"/>
<rect x="166" y="339" width="500" height="138" rx="22" class="muted"/>
<text x="198" y="380" class="tag">LEASE LIMIT</text>
<text x="198" y="422" class="label">worker-A всё ещё paused</text>
<text x="198" y="455" class="body">его память не очищается</text>
<path d="M416 477 L416 529" class="line"/>
<text x="25" y="578" class="time">t=6000</text>
<circle cx="118" cy="569" r="13" fill="#ff9a80"/>
<rect x="166" y="529" width="500" height="151" rx="22" class="warn"/>
<text x="198" y="571" class="tag">AUTHORITY</text>
<text x="198" y="613" class="label">lease-1 истёк и освобождён</text>
<text x="198" y="646" class="body">это не остановка worker-A</text>
<path d="M416 680 L416 732" class="line"/>
<text x="25" y="780" class="time">t=6000</text>
<circle cx="118" cy="771" r="13" fill="#48c5be"/>
<rect x="166" y="732" width="500" height="160" rx="22" class="accent"/>
<text x="198" y="774" class="tag">WORKER-B · ACQUIRE</text>
<text x="198" y="816" class="label">lease-2, fence token 2</text>
<text x="198" y="849" class="body">новое поколение владельца</text>
<path d="M416 892 L416 944" class="line"/>
<text x="25" y="993" class="time">t=6100</text>
<circle cx="118" cy="984" r="13" fill="#67d5a6"/>
<rect x="166" y="944" width="500" height="153" rx="22" class="ok"/>
<text x="198" y="986" class="tag">PROTECTED RESOURCE</text>
<text x="198" y="1028" class="label">write token 2 → accepted</text>
<text x="198" y="1061" class="body">highest accepted token = 2</text>
<path d="M416 1097 L416 1149" class="line"/>
<text x="25" y="1198" class="time">t=6200</text>
<circle cx="118" cy="1189" r="13" fill="#ff9a80"/>
<rect x="166" y="1149" width="500" height="190" rx="22" class="warn"/>
<text x="198" y="1191" class="tag">LATE REQUEST FROM A</text>
<text x="198" y="1233" class="label">write token 1 → rejected</text>
<text x="198" y="1266" class="body">ресурс не даёт v1 перезаписать v2</text>
<text x="198" y="1298" class="small">без resource-side fence</text>
<text x="198" y="1326" class="small">обе записи допустимы</text>
<text x="360" y="1372" text-anchor="middle" class="small">Lock координирует владение; ресурс проверяет поколение.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

+686
View File
@@ -0,0 +1,686 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function dataTable(caption, headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table><caption>' + escapeHtml(caption) + '</caption>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
function createRevision(meta, bodyParts, sources) {
if (sources.length < 2) {
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
}
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
const length = bodyText(contentHtml).length;
if (length < 5000 || length > 15000) {
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + length);
}
return { ...meta, contentHtml };
}
const etcd34Lock = {
title: 'etcd v3.4: Concurrency API Reference — Lock и Unlock',
url: 'https://etcd.io/docs/v3.4/dev-guide/api_concurrency_reference_v3/',
note: 'версионная линия v3.4 существовала до мая 2021 года: Lock возвращает key на время владения, а истечение или отзыв привязанного lease освобождает lock; документация не объявляет этот key универсальным fencing token для внешнего ресурса',
};
const etcd34Lease = {
title: 'etcd v3.4: API Reference — LeaseGrant и LeaseKeepAlive',
url: 'https://etcd.io/docs/v3.4/dev-guide/api_reference_v3/',
note: 'LeaseGrant получает advisory TTL; LeaseGrantResponse возвращает TTL, выбранный server. Без keepAlive lease истекает, а прикреплённые keys удаляются; учебная длительность ниже не является настройкой etcd',
};
const chubbyPaper = {
title: 'Mike Burrows, The Chubby lock service for loosely-coupled distributed systems, OSDI 2006',
url: 'https://research.google/pubs/the-chubby-lock-service-for-loosely-coupled-distributed-systems/',
note: 'первичная публикация: locks advisory, а sequencer передаётся защищаемому серверу, который сам проверяет актуальность и отклоняет устаревший запрос; это исторический пример границы fencing',
};
const trainingNotice = 'Все workers, lease, token, значения ресурса и времена ниже учебные. Модель работает только с Map и счётчиком времени в памяти: она не запускает etcd, Redis, ZooKeeper, базу, broker, HTTP, сетевой тест, синхронизацию часов или cluster.';
function assertNonEmptyString(value, label) {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(label + ' must be a non-empty string');
}
}
function assertPositiveInteger(value, label) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(label + ' must be a positive integer');
}
}
/**
* Учебная модель разделяет два владельца состояния.
* Lock authority помнит только активный lease и выдаёт возрастающий token.
* Resource хранит наибольший принятый token и не спрашивает authority перед write.
* Это не API какого-либо lock provider и не доказательство поведения кластера.
*/
export function createDistributedLockTrainingModel() {
let nowMs = 0;
let nextLeaseNumber = 0;
let nextFenceToken = 0;
let activeLock = null;
const leaseHistory = [];
const resource = {
acceptedFenceToken: 0,
value: null,
writes: [],
};
function expireActiveLeaseIfNeeded() {
if (activeLock && nowMs >= activeLock.expiresAtMs) {
const expired = { ...activeLock, expiredAtMs: nowMs };
leaseHistory.push({ event: 'lease-expired', ...expired });
activeLock = null;
return expired;
}
return null;
}
function advanceTo(nextNowMs) {
if (!Number.isInteger(nextNowMs) || nextNowMs < nowMs) {
throw new Error('training clock only moves forward by integer milliseconds');
}
nowMs = nextNowMs;
return expireActiveLeaseIfNeeded();
}
function acquire({ lockName, ownerId, leaseMs }) {
assertNonEmptyString(lockName, 'lockName');
assertNonEmptyString(ownerId, 'ownerId');
assertPositiveInteger(leaseMs, 'leaseMs');
const expired = expireActiveLeaseIfNeeded();
if (activeLock) {
return {
status: 'busy',
atMs: nowMs,
lockName,
ownerId,
heldBy: activeLock.ownerId,
heldFenceToken: activeLock.fenceToken,
expiryObserved: expired,
};
}
const lock = Object.freeze({
lockName,
ownerId,
leaseId: 'training-lease-' + (++nextLeaseNumber),
fenceToken: ++nextFenceToken,
acquiredAtMs: nowMs,
expiresAtMs: nowMs + leaseMs,
});
activeLock = lock;
leaseHistory.push({ event: 'lease-acquired', ...lock });
return {
status: 'acquired',
atMs: nowMs,
expiryObserved: expired,
lock,
};
}
function release({ ownerId, leaseId, fenceToken }) {
assertNonEmptyString(ownerId, 'ownerId');
assertNonEmptyString(leaseId, 'leaseId');
assertPositiveInteger(fenceToken, 'fenceToken');
const expired = expireActiveLeaseIfNeeded();
if (!activeLock) {
return {
status: 'no-active-lease',
atMs: nowMs,
ownerId,
leaseId,
fenceToken,
expiryObserved: expired,
};
}
const ownsCurrentLease = activeLock.ownerId === ownerId
&& activeLock.leaseId === leaseId
&& activeLock.fenceToken === fenceToken;
if (!ownsCurrentLease) {
return {
status: 'release-rejected-not-owner',
atMs: nowMs,
requested: { ownerId, leaseId, fenceToken },
active: { ...activeLock },
};
}
const released = activeLock;
activeLock = null;
leaseHistory.push({ event: 'lease-released', ...released, releasedAtMs: nowMs });
return { status: 'released', atMs: nowMs, lock: released };
}
function writeProtectedResource({ ownerId, fenceToken, value }) {
assertNonEmptyString(ownerId, 'ownerId');
assertPositiveInteger(fenceToken, 'fenceToken');
assertNonEmptyString(value, 'value');
const request = {
atMs: nowMs,
ownerId,
fenceToken,
value,
resourceHighestTokenBefore: resource.acceptedFenceToken,
};
if (fenceToken <= resource.acceptedFenceToken) {
const rejected = {
...request,
status: 'rejected-stale-fence',
resourceHighestTokenAfter: resource.acceptedFenceToken,
};
resource.writes.push(rejected);
return rejected;
}
resource.acceptedFenceToken = fenceToken;
resource.value = value;
const accepted = {
...request,
status: 'accepted',
resourceHighestTokenAfter: resource.acceptedFenceToken,
};
resource.writes.push(accepted);
return accepted;
}
function inspect() {
return {
nowMs,
activeLock: activeLock ? { ...activeLock } : null,
leaseHistory: leaseHistory.map((entry) => ({ ...entry })),
resource: {
acceptedFenceToken: resource.acceptedFenceToken,
value: resource.value,
writes: resource.writes.map((entry) => ({ ...entry })),
},
};
}
return {
advanceTo,
acquire,
release,
writeProtectedResource,
inspect,
};
}
function writeWithoutFence(unsafeResource, request) {
unsafeResource.value = request.value;
unsafeResource.writes.push({ ...request, status: 'accepted-without-fence' });
return unsafeResource.value;
}
/**
* Детерминированный interleaving без часов ОС или сети:
* worker-A берёт lease-1/token-1, останавливается после expiry;
* worker-B берёт lease-2/token-2 и записывает ресурс;
* старый worker-A возвращается, но ресурс отвергает token-1.
*/
export function runDistributedLockFixture() {
const model = createDistributedLockTrainingModel();
const lockName = 'report:417:rebuild';
const firstAcquire = model.acquire({
lockName,
ownerId: 'worker-A',
leaseMs: 5000,
});
const firstLock = firstAcquire.lock;
const expiry = model.advanceTo(6000);
const secondAcquire = model.acquire({
lockName,
ownerId: 'worker-B',
leaseMs: 5000,
});
const secondLock = secondAcquire.lock;
const secondWrite = model.writeProtectedResource({
ownerId: secondLock.ownerId,
fenceToken: secondLock.fenceToken,
value: 'report-v2-from-worker-B',
});
model.advanceTo(6200);
const staleFirstWrite = model.writeProtectedResource({
ownerId: firstLock.ownerId,
fenceToken: firstLock.fenceToken,
value: 'obsolete-report-v1-from-worker-A',
});
const staleRelease = model.release({
ownerId: firstLock.ownerId,
leaseId: firstLock.leaseId,
fenceToken: firstLock.fenceToken,
});
const unsafeResource = { value: null, writes: [] };
writeWithoutFence(unsafeResource, {
ownerId: secondLock.ownerId,
fenceToken: secondLock.fenceToken,
value: 'report-v2-from-worker-B',
});
writeWithoutFence(unsafeResource, {
ownerId: firstLock.ownerId,
fenceToken: firstLock.fenceToken,
value: 'obsolete-report-v1-from-worker-A',
});
const snapshot = model.inspect();
const assertions = {
firstWorkerAcquiredLeaseOne: firstAcquire.status === 'acquired'
&& firstLock.leaseId === 'training-lease-1'
&& firstLock.fenceToken === 1,
ownerPausePassedExpiry: expiry && expiry.ownerId === 'worker-A'
&& expiry.expiresAtMs === 5000
&& expiry.expiredAtMs === 6000,
secondWorkerAcquiredNewLease: secondAcquire.status === 'acquired'
&& secondLock.leaseId === 'training-lease-2'
&& secondLock.fenceToken === 2,
secondWriteWasAcceptedByResource: secondWrite.status === 'accepted'
&& secondWrite.resourceHighestTokenAfter === 2,
staleFirstWriteWasRejectedByResource: staleFirstWrite.status === 'rejected-stale-fence'
&& staleFirstWrite.resourceHighestTokenBefore === 2
&& staleFirstWrite.resourceHighestTokenAfter === 2,
staleOwnerCannotReleaseNewLease: staleRelease.status === 'release-rejected-not-owner'
&& staleRelease.active.ownerId === 'worker-B',
protectedResourceRetainedNewerValue: snapshot.resource.value === 'report-v2-from-worker-B'
&& snapshot.resource.acceptedFenceToken === 2,
noFenceWouldAllowLateOverwrite: unsafeResource.value === 'obsolete-report-v1-from-worker-A'
&& unsafeResource.writes.length === 2,
modelStayedInMemory: snapshot.leaseHistory.length === 3
&& snapshot.resource.writes.length === 2,
};
if (!Object.values(assertions).every(Boolean)) {
throw new Error('distributed lock training fixture violated a documented invariant');
}
return {
model: 'deterministic in-memory lease and fencing model; not a provider, clock, or cluster',
timeline: {
firstAcquire,
expiry,
secondAcquire,
secondWrite,
staleFirstWrite,
staleRelease,
},
unsafeContrast: unsafeResource,
snapshot,
assertions,
};
}
const acquireCode = [
'const lease = authority.acquire({',
" lockName: 'report:417:rebuild',",
" ownerId: 'worker-A',",
' leaseMs: 5000,',
'});',
'',
'// Учебный результат: leaseId=training-lease-1, fenceToken=1.',
'// Token принадлежит поколению владения, не времени на часах worker-A.',
].join('\n');
const interleavingCode = [
't=0 worker-A acquire lease-1, fence=1',
't=6000 worker-A всё ещё paused; lease-1 уже истёк',
't=6000 worker-B acquire lease-2, fence=2',
't=6100 resource.write(fence=2, value=v2) -> accepted',
't=6200 worker-A resumes',
't=6200 resource.write(fence=1, value=v1) -> rejected-stale-fence',
'',
'// Владелец lease-1 не исчезает из памяти процесса только потому,',
'// что authority уже выдал lease-2 другому worker.',
].join('\n');
const protectedWriteCode = [
'function writeProtectedResource(state, request) {',
' if (request.fenceToken <= state.acceptedFenceToken) {',
" return { status: 'rejected-stale-fence' };",
' }',
'',
' state.acceptedFenceToken = request.fenceToken;',
' state.value = request.value;',
" return { status: 'accepted' };",
'}',
'',
'// Важна проверка внутри ресурса, который меняется.',
].join('\n');
const unsafeWriteCode = [
'function writeWithoutFence(state, request) {',
' state.value = request.value;',
" return { status: 'accepted-without-fence' };",
'}',
'',
"writeWithoutFence(state, { fenceToken: 2, value: 'v2' });",
"writeWithoutFence(state, { fenceToken: 1, value: 'old-v1' });",
'// state.value === old-v1: поздний владелец переписал новое значение.',
].join('\n');
const releaseCode = [
'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.',
].join('\n');
const evidenceCode = [
'const evidence = {',
" lockName: 'report:417:rebuild',",
" ownerId: 'worker-A',",
" leaseId: 'training-lease-1',",
' fenceToken: 1,',
' resourceHighestTokenBefore: 2,',
" result: 'rejected-stale-fence',",
'};',
'',
'// Такой пакет объясняет отказ без вывода о точности реальных часов.',
].join('\n');
const fixtureCode = [
'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",
].join('\n');
const practiceArticle = createRevision(
{
slug: 'editorial-2021-05-practice-distributed-locks',
title: 'Lease-блокировка: что проверить до критической секции',
categories: ['Надёжность', 'Backend', 'Практика'],
cover: '/assets/editorial/2021/distributed-lock-lease-timeline-2021.svg',
excerpt: 'Lease не делает остановившийся worker безопасным. Разбираем учебный interleaving: истёкший lease, новое владение, старый запрос и fence token, который должен проверить сам защищаемый ресурс.',
readingMinutes: 15,
},
[
paragraph('Симптом выглядит как редкая гонка: worker-A взял блокировку для пересборки отчёта, остановился дольше lease, а worker-B после истечения уже выполнил ту же критическую секцию. Затем worker-A просыпается и отправляет старую запись. Цена ошибки не в том, что два процесса увидели один lock. Цена в том, что поздняя запись может перетереть более новое состояние, хотя второй worker действовал по выданному ему lease.'),
paragraph('В мае 2021 года я бы не называл распределённую блокировку универсальной защитой. Lease ограничивает период владения в lock authority. Он не отменяет уже созданный запрос, не останавливает paused процесс и не заставляет внешний ресурс спросить authority перед записью. Поэтому ниже есть маленький договор из owner, leaseId, fence token и проверки на стороне ресурса. ' + trainingNotice),
heading('Сначала разделяем lock и ресурс, который меняется'),
paragraph('Критическая секция часто записывается одной фразой: «взяли lock — обновили данные». В ней скрыты два разных состояния. Lock authority знает, кому сейчас принадлежит имя <code>report:417:rebuild</code> и когда истекает lease. Защищаемый ресурс знает, какое значение он уже принял. Если ресурсом является таблица, файл, API партнёра или другой сервис, lock authority не получает автоматического права отклонять его поздние запросы. Эту границу надо назвать до выбора клиента и TTL.'),
dataTable(
'Учебный контракт одной критической секции',
['Поле', 'Кто владеет', 'Что проверяем', 'Чего поле не доказывает'],
[
['<code>lockName</code>', 'lock authority', 'оба worker спорят за один и тот же предмет', 'что все связанные ресурсы вошли в тот же lock'],
['<code>ownerId</code>', 'клиент/журнал запроса', 'какой worker отправил действие', 'что процесс всё ещё исполняется или имеет право писать'],
['<code>leaseId</code>', 'lock authority', 'release относится к текущему захвату', 'что старый request уже исчез из сети'],
['<code>fenceToken</code>', 'поколение владения и ресурс', 'resource принимает только token больше последнего принятого', 'что token сам по себе отменяет старый request'],
['<code>acceptedFenceToken</code>', 'защищаемый ресурс', 'поздний владелец не перезапишет новое значение', 'что resource получил все запросы или делает exactly-once'],
],
),
paragraph('Здесь <code>ownerId</code> нужен для наблюдения, а не для авторизации. <code>leaseId</code> позволяет не снять чужой 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 полезен как историческая граница: <code>Lock</code> возвращает 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 получил <code>lease-1</code>. В t=6000 модель отмечает его истекшим: worker-A не присылал keepalive, но сам объект worker-A не уничтожается. В тот же момент worker-B получает <code>lease-2</code> с token 2. Он первым меняет ресурс. В t=6200 старый worker-A продолжает ранее начатую работу. Самая важная строка — не выдача второго lease, а ответ ресурса на request с token 1.'),
codeBlock(interleavingCode),
paragraph('Такой порядок не требует утверждать, что реальные часы точны или что провайдер всегда мгновенно наблюдает остановку клиента. Он фиксирует более простой факт: request может жить дольше представления worker о своём владении. Поэтому проверка <code>lease is active</code> только перед началом работы недостаточна. Между проверкой и write lease может закончиться, другой worker получить новое поколение, а первый request всё равно дойти до ресурса.'),
heading('Ресурс принимает только новое поколение'),
paragraph('Проверка fencing должна находиться там, где появляется эффект. В модели ресурс хранит <code>acceptedFenceToken</code>. Request с token 2 увеличивает это значение и записывает <code>report-v2-from-worker-B</code>. Следующий request с token 1 не сравнивается с локальным clock worker-A и не делает сетевой запрос к authority. Ресурс видит уже принятый 2 и возвращает <code>rejected-stale-fence</code>. Так поздняя работа становится наблюдаемым отказом, а не тихой порчей значения.'),
codeBlock(protectedWriteCode),
paragraph('Требование строгого <code>&gt;</code> имеет смысл только для одного доменного предмета и одной линии поколений. Если два независимых действия действительно могут выполняться параллельно, им не стоит навязывать общий 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 получает <code>release-rejected-not-owner</code>; worker-B продолжает владеть lease-2. Это отдельная проверка от fencing: она защищает lock authority, а не запись в ресурс.'),
codeBlock(releaseCode),
paragraph('Не стоит заменять эту проверку случайной строкой owner. Owner нужен, чтобы оператор видел автора действия. Для release важен неповторимый handle текущего захвата, который выдал provider. Для защищённой записи важен token, который ресурс способен сравнить. Один идентификатор может участвовать в обоих протоколах только если это явно задокументировано и проверено в конкретной интеграции. Учебная модель намеренно не делает такого вывода.'),
heading('Маршрут проверки перед критической секцией'),
orderedList([
'Назвать один предмет блокировки и один ресурс, который реально меняется. Если список ресурсов разный, не скрывать это за одним широким <code>lockName</code>.',
'Зафиксировать, что 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('В учебной функции состояние ресурса содержит <code>acceptedFenceToken</code>. Перед записью сравниваем request с этим состоянием. В настоящем SQL это может быть условие в одном <code>UPDATE</code>; в объектном storage — версия при записи; в сервисе — проверка и хранение token внутри его транзакционной границы. Форма меняется, инвариант нет: чтение последнего token и фиксация нового значения не должны разъехаться между двумя независимыми действиями.'),
codeBlock(protectedWriteCode),
paragraph('Проверка не должна принимать равный token как новый. Равенство часто означает повторный request того же владельца. Можно отдельно проектировать идемпотентный ответ для одинакового operation id, но это другой contract. В минимальной модели token служит только для сравнения поколений, поэтому <code>&lt;=</code> отвергается. Не нужно смешивать этот результат с 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 <code>Lock</code> возвращает уникальный 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 отвечает «выполняли ли уже именно эту доменную операцию?». Иногда одно сообщение несёт все три поля, но проверять их нужно по разным правилам. Замена их одним <code>requestId</code> делает журнал удобнее, а поведение — неяснее: старый request может иметь уникальный id, но всё равно не иметь права перезаписывать новый результат.'),
dataTable(
'Три поля для трёх разных проверок',
['Поле', 'Сравнение', 'Пример отказа', 'Не заменяет'],
[
['<code>ownerId</code>', 'с текущим handle при release', 'старый A пытается снять lease-B', 'resource-side fence'],
['<code>fenceToken</code>', 'строго больше highest token ресурса', 'token 1 приходит после принятого token 2', 'идемпотентность одного effect'],
['<code>operationId</code>', 'с ledger операции', 'повтор delivery того же business action', 'порядок поколений владельца'],
['<code>leaseId</code>', 'точное равенство текущему 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('Поле <code>resourceHighestTokenBefore</code> особенно полезно. Если оно уже равно 2, а request несёт 1, причина отказа читается без предположения «worker-A точно спал шесть секунд». Мы видим, что ресурс уже принял более новое поколение. Если highest token ещё 0, ситуация другая: возможно, новый owner не успел записать, либо request ушёл к другому resource key. Эти ветви нельзя склеивать в одну метку <code>lock-error</code>.'),
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 берёт <code>training-lease-1</code> с token 1. Модель переводит clock в 6000 мс и отмечает lease истёкшим. Worker-B получает <code>training-lease-2</code>, token 2 и записывает v2. Потом A возвращается с v1. Ресурс смотрит на свой highest token, а не на память A, и возвращает <code>rejected-stale-fence</code>.'),
codeBlock(fixtureCode),
paragraph('Такой тест не проверяет фактический provider. Он проверяет, что команда не потеряла нужный сценарий в обсуждении. Если кто-то заменит сравнение <code>&lt;=</code> на безусловный write, assertion <code>protectedResourceRetainedNewerValue</code> перестанет выполняться. Если убрать 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, не отзывает переменную <code>leaseId</code> из памяти и не гарантирует отмену уже отправленного 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 по одному <code>lockName</code>. Если authority принимает такую операцию, A способен снять lease-2 worker-B. Это не отменяет fencing у ресурса, но создаёт новую конкуренцию для последующих worker.'),
codeBlock(releaseCode),
paragraph('В учебной модели release содержит owner, leaseId и token. Модель не даёт старому A удалить активный B и фиксирует <code>release-rejected-not-owner</code>. Конкретный 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');
}