TTL-блокировка истекла: как fencing token останавливает старый воркер
На безопасной модели показываем, почему истечение аренды не останавливает старый процесс и как монотонный fencing token не даёт ему перезаписать результат нового владельца.

В этой статье
Если фоновый воркер получил аренду задания на пять секунд, это ещё не значит, что после пятой секунды он физически перестанет выполнять код. Процесс может зависнуть, потерять связь с координатором или просто надолго остановиться, а затем продолжить работу уже после того, как задание получил другой воркер. Для записи, от которой зависит корректность данных, к аренде нужен fencing token — монотонно растущий номер владельца. Хранилище принимает новый номер и отклоняет любой более старый.
На маленьком примере ниже первый воркер получает token 1, переживает окончание аренды и пытается записать старый остаток товара. Второй воркер уже получил token 2 и записал более новое значение. Обычная TTL-блокировка допускает позднюю перезапись, а проверка токена сохраняет результат нового владельца.
Что документировано, а что проверено здесь
В документации Hazelcast FencedLock 5.7 описан сценарий с паузой клиента, потерей сессии и новым владельцем; внешний ресурс должен отклонить запрос со старым монотонным токеном. Репозиторий etcd содержит исполняемый пример с той же границей: аренда может истечь, поэтому защищаемое хранилище проверяет версию запроса.
Учебный прогон не использует Hazelcast, etcd, Redis, сеть или 1С-Битрикс. Он отдельно воспроизводит инвариант на CPython 3.12.14: после принятия token 2 запись с token 1 должна завершиться отказом. Это модель механизма, а не отчёт о внедрении у клиента.
Начальные условия
CPython 3.12.14 и только стандартная библиотека; прогон выполнен 2 ноября 2026 года.
Изолированная модель без сети, файлов, базы данных и фоновых процессов; все данные заданы в коде.
Время задаётся целыми условными секундами. Воркер A получает аренду в t=0 до t=5, воркер B пытается войти в t=4 и получает отказ, затем получает новую аренду в t=6.
Каждая успешная выдача аренды увеличивает токен: A получает 1, B получает 2.
Сначала используется хранилище без проверки токена, затем — хранилище, которое атомарно сравнивает номер с последним принятым.
Момент | Событие | Ожидаемый результат |
|---|---|---|
t=0 | A получает аренду и token 1 | A может начать работу |
t=4 | B запрашивает занятую аренду | Отказ LockBusy |
t=5 | Аренда A заканчивается | Код A сам по себе не останавливается |
t=6 | B получает token 2 и пишет остаток товара 8 | Новое состояние принято |
t=7 | A возобновляется и пытается записать 5 | Запись с token 1 отклонена |

Воспроизводимая модель
Создайте новый временный каталог, сохраните следующий код в файл fencing_token_lab.py и запустите обычным пользователем. Пример ничего не читает, не изменяет и не удаляет вне этого файла.
from dataclasses import dataclass
class LockBusy(RuntimeError):
pass
class StaleWriter(RuntimeError):
pass
@dataclass(frozen=True)
class Lease:
owner: str
token: int
expires_at: int
class LockService:
def __init__(self) -> None:
self._counter = 0
self._lease: Lease | None = None
def acquire(self, owner: str, now: int, ttl: int) -> Lease:
if ttl <= 0:
raise ValueError("ttl must be positive")
if self._lease is not None and now < self._lease.expires_at:
raise LockBusy(f"held by {self._lease.owner}")
self._counter += 1
self._lease = Lease(owner, self._counter, now + ttl)
return self._lease
class NaiveStock:
def __init__(self) -> None:
self.value = 0
def write(self, value: int) -> None:
self.value = value
class FencedStock:
def __init__(self) -> None:
self.value = 0
self.last_token = 0
def write(self, value: int, token: int) -> None:
if token < self.last_token:
raise StaleWriter(
f"token {token} is older than accepted token {self.last_token}"
)
self.value = value
self.last_token = token
locks = LockService()
worker_a = locks.acquire("worker-a", now=0, ttl=5)
try:
locks.acquire("worker-b", now=4, ttl=5)
except LockBusy as error:
print(f"before expiry: {error}")
worker_b = locks.acquire("worker-b", now=6, ttl=5)
assert (worker_a.token, worker_b.token) == (1, 2)
print(f"new owner: a={worker_a.token} b={worker_b.token}")
naive = NaiveStock()
naive.write(8)
naive.write(5)
assert naive.value == 5
print(f"without fence: stock={naive.value}")
safe = FencedStock()
safe.write(8, worker_b.token)
try:
safe.write(5, worker_a.token)
except StaleWriter as error:
print(f"stale write: {error}")
assert safe.value == 8 and safe.last_token == 2
safe.write(9, worker_b.token)
assert safe.value == 9 and safe.last_token == 2
print(f"with fence: stock={safe.value} last_token={safe.last_token}")
Команда запуска:
python3 fencing_token_lab.py
Полученный вывод:
before expiry: held by worker-a
new owner: a=1 b=2
without fence: stock=5
stale write: token 1 is older than accepted token 2
with fence: stock=9 last_token=2
Процесс завершился с кодом 0. Проверки assert подтверждают четыре условия: до окончания аренды второй владелец не допускается, новый владелец получает больший токен, старая запись действительно перезаписывает результат в наивном хранилище и не меняет его в защищённом.
Что показал прогон
Проверка | Без fencing token | С проверкой токена |
|---|---|---|
B до t=5 | Блокировка занята | Блокировка занята |
B после t=5 | Пишет значение 8 | Пишет значение 8 с token 2 |
A возобновился в t=7 | Поздно пишет 5 поверх 8 | Получает StaleWriter |
Повторная запись B | Разрешена | Разрешена с тем же token 2 |
Итог | Остаток товара 5 — старое значение победило | Остаток товара 9, last_token 2 |
Тут есть подвох: проверять токен должен не только сервис блокировок. Его нужно передавать вместе с каждой защищённой записью, а хранилище обязано в одной атомарной операции сравнить token с последним принятым и сохранить новое значение вместе с новым максимумом. Если приложение сначала читает last_token, а потом отдельным запросом пишет данные, между этими действиями может вклиниться другой владелец.
Почему TTL и проверка владельца перед записью недостаточны
TTL отвечает на вопрос, когда координатор может выдать аренду следующему воркеру. Он не отзывает уже выполняющийся код, не вытаскивает сетевой запрос из буфера и не мешает задержанному пакету прийти позже. Даже мгновенная проверка «аренда ещё моя» не гарантирует, что следующая запись доберётся до ресурса раньше запроса нового владельца.
Fencing token переносит решение к месту побочного эффекта. Ресурс уже видел token 2 — значит token 1 устарел независимо от часов старого процесса и порядка доставки пакетов. Номер не обязан быть временем; важно, чтобы он строго рос при передаче владения и не откатывался после сбоя или восстановления.
Что fencing token не решает
Токен не делает операцию идемпотентной. Две команды одного владельца с token 2 обе допустимы; для защиты от повторного применения доставленной команды нужен отдельный operation_id и сохранённый результат.
Токен не исправляет неверный SKU, поле, цену или остаток товара и не заменяет проверку прав технического пользователя.
Если внешний API не умеет атомарно хранить и сравнивать токен, один только номер в журнале не защищает запись.
Счётчик должен оставаться монотонным при перезапуске, отказе узла и восстановлении из резервной копии. Откат счётчика разрушает гарантию.
Если клиент не получил однозначного подтверждения, что аренда выдана, нужен отдельный контракт. Клиент не должен считать себя владельцем по одному тайм-ауту.
Для нескольких независимых ресурсов нужны понятные границы блокировки: глобальная аренда на весь каталог часто создаёт лишнюю очередь, а слишком узкая не защищает общий инвариант.
Повтор с тем же владельцем разбирается отдельно в материале о дельте, operation_id и версии остатка товара. Fencing token отвечает на другой вопрос: какой владелец уже устарел после передачи аренды.
Граница с 1С-Битрикс и обменом магазина
В 1С-Битрикс: Управление сайтом похожая ситуация возможна у фонового обработчика импорта, агента, очереди обновления остатков или собственного интеграционного модуля. Но названия заданий, способ блокировки и доступные точки расширения зависят от редакции, версии платформы, модуля и конкретного направления обмена. Этот пример не проверяет штатный обмен с 1С и не предлагает вставлять токен в таблицы продукта без проектирования.
Уточните, что именно защищается: товар, торговое предложение, склад, пакет обмена или заказ.
Зафиксируйте устойчивый идентификатор SKU или торгового предложения, поля цены и остатка товара, направление обмена и источник окончательного значения.
Проверьте, где можно атомарно сравнить fencing token и записать данные с теми же правами и в той же транзакционной границе.
Смоделируйте паузу старого воркера: новый владелец должен завершить запись, а старый после возобновления — получить явный отказ, не молчаливый успех.
Отдельно повторите команду текущего владельца и подтвердите идемпотентность через operation_id, если повтор может изменить деньги, количество или статус.
Проверьте восстановление координатора и хранилища из резервной копии: последний принятый токен не должен стать больше нового счётчика.
Критерий готовности здесь измерим: после передачи аренды новый владелец записывает значение, а любой запоздавший запрос прежнего владельца отклоняется в самом хранилище. Если система умеет лишь показать, что TTL истёк, но не может остановить старую запись, блокировка пока экономит работу — корректность данных ей ещё не поручали.
If a background worker receives a task lease for five seconds, that does not mean it physically stops executing code after the fifth second. The process may hang, lose contact with the coordinator, or simply pause for a long time, only to resume after another worker has taken over the task. For any write operation that affects data correctness, a fencing token is required—a monotonically increasing owner identifier. The storage accepts the new number and rejects any older one.
In the small example below, the first worker receives token 1, survives the lease expiration, and attempts to write the old product remainder. The second worker has already received token 2 and written a newer value. A standard TTL lock allows late overwrites, while token verification preserves the new owner's result.
What is documented and what is verified here
The documentation for Hazelcast FencedLock 5.7 describes a scenario with client pause, session loss, and a new owner; the external resource must reject requests with an old monotonic token. etcd Repository contains an executable example with the same boundary: the lease may expire, so the protected storage checks the request version.
The training run does not use Hazelcast, etcd, Redis, a network, or 1C-Bitrix. It separately reproduces an invariant on CPython 3.12.14: after token 2 is accepted, the record with token 1 must fail. This is a model of the mechanism, not a report on implementation at a client site.
Initial conditions
CPython 3.12.14 and the standard library only; the run was performed on November 2, 2026.
Isolated model without network, files, database, or background processes; all data is defined in code.
Time is set in whole conditional seconds. Worker A receives a lease from t=0 to t=5, Worker B attempts to enter at t=4 and is rejected, then receives a new lease at t=6.
Each successful lease issuance increments the token: A receives 1, B receives 2.
First, a storage without token verification is used, then a storage that atomically compares the number with the last accepted one.
Moment | Event | Expected result |
|---|---|---|
t=0 | A receives the lease and token 1 | A can start working |
t=4 | B requests a leased resource | LockBusy rejection |
t=5 | A's lease expires | Code A does not stop on its own |
t=6 | B receives token 2 and writes the remaining product quantity 8 | New state accepted |
t=7 | A resumes and attempts to write 5 | Write with token 1 rejected |

Reproducible model
Create a new temporary directory, save the following code to the file fencing_token_lab.py, and run it as a regular user. The example reads nothing, changes nothing, and deletes nothing outside this file.
from dataclasses import dataclass
class LockBusy(RuntimeError):
pass
class StaleWriter(RuntimeError):
pass
@dataclass(frozen=True)
class Lease:
owner: str
token: int
expires_at: int
class LockService:
def __init__(self) -> None:
self._counter = 0
self._lease: Lease | None = None
def acquire(self, owner: str, now: int, ttl: int) -> Lease:
if ttl <= 0:
raise ValueError("ttl must be positive")
if self._lease is not None and now < self._lease.expires_at:
raise LockBusy(f"held by {self._lease.owner}")
self._counter += 1
self._lease = Lease(owner, self._counter, now + ttl)
return self._lease
class NaiveStock:
def __init__(self) -> None:
self.value = 0
def write(self, value: int) -> None:
self.value = value
class FencedStock:
def __init__(self) -> None:
self.value = 0
self.last_token = 0
def write(self, value: int, token: int) -> None:
if token < self.last_token:
raise StaleWriter(
f"token {token} is older than accepted token {self.last_token}"
)
self.value = value
self.last_token = token
locks = LockService()
worker_a = locks.acquire("worker-a", now=0, ttl=5)
try:
locks.acquire("worker-b", now=4, ttl=5)
except LockBusy as error:
print(f"before expiry: {error}")
worker_b = locks.acquire("worker-b", now=6, ttl=5)
assert (worker_a.token, worker_b.token) == (1, 2)
print(f"new owner: a={worker_a.token} b={worker_b.token}")
naive = NaiveStock()
naive.write(8)
naive.write(5)
assert naive.value == 5
print(f"without fence: stock={naive.value}")
safe = FencedStock()
safe.write(8, worker_b.token)
try:
safe.write(5, worker_a.token)
except StaleWriter as error:
print(f"stale write: {error}")
assert safe.value == 8 and safe.last_token == 2
safe.write(9, worker_b.token)
assert safe.value == 9 and safe.last_token == 2
print(f"with fence: stock={safe.value} last_token={safe.last_token}")
Command run:
python3 fencing_token_lab.py
Output obtained:
before expiry: held by worker-a
new owner: a=1 b=2
without fence: stock=5
stale write: token 1 is older than accepted token 2
with fence: stock=9 last_token=2
The process exited with code 0. The assert checks confirm four conditions: the second owner is not allowed before the lease ends, the new owner receives a higher token, the old record indeed overwrites the result in a naive store, and it does not change it in a protected store.
What the run showed
Verification | Without fencing token | With token check |
|---|---|---|
B until t=5 | Lock busy | Lock busy |
B after t=5 | Writes value 8 | Writes value 8 with token 2 |
A resumed at t=7 | Late write 5 over 8 | Receives StaleWriter |
Retry write B | Allowed | Allowed with same token 2 |
Result | Remaining stock 5 — old value won | Product remaining quantity 9, last_token 2 |
There is a catch: the token must be checked not only by the blocking service. It must be sent with every protected record, and the storage must atomically compare the token with the last accepted one and save the new value along with the new maximum. If the application first reads last_token and then writes data in a separate request, another owner can intervene between these actions.
Why TTL and owner verification before writing are insufficient
TTL answers the question of when the coordinator can issue a lease to the next worker. It does not revoke already executing code, does not pull a network request out of the buffer, and does not prevent a delayed packet from arriving later. Even an instantaneous check that the lease is still mine does not guarantee that the next record will reach the resource before the new owner's request.
Fencing token moves the decision to the site of the side effect. The resource has already seen token 2, meaning token 1 is stale regardless of the old process's clock and packet delivery order. The number does not have to be a timestamp; it is important that it strictly increases during ownership transfer and does not roll back after a failure or recovery.
What fencing token does not solve
A token does not make an operation idempotent. Two commands from the same owner with token 2 are both valid; to protect against reapplying a delivered command, a separate operation_id and a saved result are required.
A token does not correct an incorrect SKU, field, price, or product remaining quantity, nor does it replace permission checks for a technical user.
If an external API cannot atomically store and compare the token, a number in the log alone does not protect the record.
The counter must remain monotonic across restarts, node failures, and restores from backup. Rolling back the counter breaks the guarantee.
If a client does not receive unambiguous confirmation that the lease was granted, a separate contract is needed. A client must not consider themselves the owner based solely on a timeout.
For multiple independent resources, clear lock boundaries are needed: a global lease for the entire catalog often creates unnecessary queues, while a lock that is too narrow fails to protect the shared invariant.
Retries with the same owner are handled separately in the material on delta, operation_id, and product remaining quantity version. A fencing token answers a different question: which owner is already stale after the lease transfer.
Boundary with 1C-Bitrix and store exchange
In 1C-Bitrix: Site Management, a similar situation is possible for the background import handler, agent, stock update queue, or a custom integration module. However, task names, blocking mechanisms, and available extension points depend on the edition, platform version, module, and specific exchange direction. This example does not verify standard 1C exchange and does not propose inserting a token into product tables without prior design.
Clarify exactly what is being protected: the product, product variant, warehouse, exchange package, or order.
Fix a stable SKU or product variant identifier, price and stock fields, exchange direction, and the source of the final value.
Verify where you can atomically compare the fencing token and write data with the same permissions and within the same transactional boundary.
Simulate a pause in the old worker: the new owner must complete the write, and the old one, upon resuming, must receive an explicit rejection, not a silent success.
Repeat the current owner's command separately and confirm idempotency via operation_id if the repeat could alter money, quantity, or status.
Verify recovery of the coordinator and storage from a backup: the last accepted token must not exceed the new counter.
The readiness criterion here is measurable: after the lease transfer, the new owner writes the value, and any delayed request from the previous owner is rejected directly in the storage. If the system can only show that the TTL has expired but cannot stop the old write, the lock still saves work—data correctness has not yet been entrusted to it.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.