~bigbes/tarantool-etcd · parade

main · last commit 1 month ago · 2npkec0r

← Back to the parade

tarantool-etcd-3sp LeaseKeepAlive fsyncs WAL on every renew Past Stand

status: closed P2 bug @Eugene Blikh
bd reopen tarantool-etcd-3sp
Created byEugene Blikh
Ownerbigbes@gmail.com
Created2026-05-19T17:33:47Z
Started2026-05-20T12:46:48Z
Updated2026-05-20T12:53:33Z
Closed2026-05-20T12:53:33Z
Description
On btrfs SSD bench (2026-05-19 disk run), LeaseKeepAlive/n_X drops from 4 234 ops/s (tmpfs) to 207 ops/s on tarantool while etcd holds at ~7 400 ops/s in both configs.

p50 goes from 224 µs (tmpfs) to 4 548 µs (disk) — exactly one btrfs fsync. Root cause at app/etcd/lease.lua:191:

    box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})

The leases space is is_sync=true so every keep-alive does a full WAL append + fsync. Etcd's KeepAlive is an in-memory TTL bump with no disk write.

Fix sketch: split expiry into an in-memory map (refreshed on every KeepAlive) and persist only on grant/revoke. The expiry fiber should read from the in-memory map. Trade-off: leases may extend further into the future than the on-disk record after a crash, which is fine — clients have to re-keep-alive after reconnect anyway.

See docs/BENCH.md Linux x86_64 disk section (Open follow-ups).
Design
GOAL: lease keep-alive must not touch the WAL. Mirror etcd's split — durable
state = {lease record, granted TTL, attached keys}; ephemeral leader-local
state = the expiry deadline (refreshed on every keep-alive, never persisted).

=== STATE MODEL ===
Add a module-level in-memory map in app/etcd/lease.lua:
    local deadlines = {}   -- [lease_id] = monotonic deadline (clock.monotonic()+ttl)
This is leader-local. Followers never keep-alive (the gRPC write gate routes
LeaseKeepAlive to the leader), and only the leader runs the expiry fiber and
can revoke. So the countdown is structurally a leader concern.

The `leases` space (schema.lua:97-118) stays is_sync=true and keeps its
{id, ttl, expiry_time, granted_ttl} format. The `expiry_time` FIELD and its
TREE index (schema.lua:113) become vestigial — written once at grant, never
the source of liveness again. Leave them to avoid a space migration; an
optional follow-up can drop the field+index. Nothing reads expiry_time after
this change.

=== HANDLER CHANGES (app/etcd/lease.lua) ===
1. grant() ~L70: after `box.space.leases:insert(...)`, seed
   `deadlines[id] = now + ttl`. Insert still persists (durable, required).
2. keepalive() ~L191: REPLACE
       box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})
   with
       deadlines[id] = clock.monotonic() + lease.ttl
   Keep the `box.space.leases:get(id)` existence check above it (cheap read,
   no fsync) — it supplies lease.ttl and preserves the NOT_FOUND-raises
   contract the gRPC stream handler depends on (see L169-171).
3. revoke() ~L150: after `box.space.leases:delete(id)`, clear
   `deadlines[id] = nil`.
4. time_to_live() ~L236: REPLACE `lease.expiry_time` read with `deadlines[id]`.
   If deadlines[id] is nil (lease loaded but not yet rebuilt), fall back to
   `clock.monotonic() + lease.granted_ttl`. remaining = max(0, floor(dl - now)).
5. expiry_loop() L287-338: REPLACE the `box.space.leases.index.expiry_time`
   scan (L302-309) with a scan of `deadlines`: collect ids where
   `deadline <= now`. Plain full-table scan is fine (numeric compares in Lua,
   runs every 500ms); a min-heap (etcd's leaseExpiredNotifier shape) is the
   optional optimization if lease counts get large. revoke() already nils the
   map entry on success.

=== REBUILD ON PROMOTE ===
Add lease.M.rebuild_deadlines():
    clears `deadlines`, then for each tuple in box.space.leases:pairs() sets
    deadlines[tuple.id] = clock.monotonic() + tuple.ttl
Call it in the RW branch of the box.watch('box.status', ...) callback in
app/roles/etcd.lua (L341-353), immediately before lease.start_expiry().
This resets every inherited lease's deadline to a full-TTL grace period on the
new leader — exactly etcd's failover behavior.

=== LATENT BUG THIS ALSO FIXES ===
expiry_time is currently computed as clock.monotonic()+ttl and REPLICATED.
clock.monotonic() is process-local (relative to boot), so the persisted value
is meaningless on any other node. Today a promoted replica's fiber scans
inherited expiry_time values from a different monotonic timeline — could
expire leases instantly or never. Rebuild-on-promote with the new leader's own
clock removes this entirely.
Acceptance criteria
- keepalive issues ZERO WAL writes: box.info.lsn delta == 0 across N successive
  M.keepalive() calls on the same lease.
- BenchmarkLeaseKeepAlive/n_* on btrfs SSD (work.lab.local, real-disk run)
  recovers from 207 ops/s to within ~2x of etcd (~4000+ ops/s, near the tmpfs
  baseline). Re-run: TMPDIR=$HOME/bench-data go test -run=^$ \
  -bench=^BenchmarkLeaseKeepAlive$ -benchtime=5s ./bench/
- Lease still expires correctly: grant short TTL, no keep-alive, attached keys
  are deleted by the fiber after TTL elapses; keep-alive before TTL prevents it.
- TimeToLive reflects the latest keep-alive (remaining resets to ~ttl after a
  renew).
- Promote rebuilds deadlines: a freshly-promoted leader expires inherited
  leases using its own monotonic clock (no instant-expiry, no never-expiry).
- All existing lease conformance cells (tarantool, tarantool_json,
  tarantool_rs3) and Lua lease tests pass.
Notes
TESTS (add Lua-side tests pinning the fix per the project's regression rule):
- no-WAL: assert box.info.lsn unchanged across keepalive calls.
- liveness: keepalive refreshes deadline (TimeToLive resets, lease survives).
- promote rebuild: use the box.cfg{read_only=true}...{read_only=false}+
  box.ctl.promote() in-process follower-simulation pattern (see role_test.lua
  group role/write_gates_in_process) to verify rebuild_deadlines repopulates.
- cross-node: optionally exercise via the replicaset harness that a promoted
  replica expires an inherited lease.
Conformance already covers cross-wire correctness; this is about the Lua unit
pins + the bench recovery number.

DOCS: update docs/BENCH.md Linux x86_64 disk section once re-benched; remove
the LeaseKeepAlive "bug" annotation from the headline table.

SCOPE NOTE: leave the expiry_time field + index in place (no migration). Only
the keepalive write path and the fiber's liveness source change.

Depends on

Depended on by

Nothing depends on this issue.

No comments.

Close reason

keep-alive now bumps a leader-local in-memory deadline map (zero WAL on renew); leases space written only on grant/revoke; rebuild_deadlines on promote. Lua regression tests + TestLease conformance pass. Disk re-bench on btrfs SSD still pending to replace pre-fix BENCH.md numbers.
  • Eugene Blikh created the issue · 2026-05-19T20:33:47Z
  • Eugene Blikh updated design to GOAL: lease keep-alive must not touch the WAL. Mirror etcd's split — durable state = {lease record, granted TTL, attached keys}; ephemeral leader-local state = the expiry deadline (refreshed on every keep-alive, never persisted). === STATE MODEL === Add a module-level in-memory map in app/etcd/lease.lua: local deadlines = {} -- [lease_id] = monotonic deadline (clock.monotonic()+ttl) This is leader-local. Followers never keep-alive (the gRPC write gate routes LeaseKeepAlive to the leader), and only the leader runs the expiry fiber and can revoke. So the countdown is structurally a leader concern. The `leases` space (schema.lua:97-118) stays is_sync=true and keeps its {id, ttl, expiry_time, granted_ttl} format. The `expiry_time` FIELD and its TREE index (schema.lua:113) become vestigial — written once at grant, never the source of liveness again. Leave them to avoid a space migration; an optional follow-up can drop the field+index. Nothing reads expiry_time after this change. === HANDLER CHANGES (app/etcd/lease.lua) === 1. grant() ~L70: after `box.space.leases:insert(...)`, seed `deadlines[id] = now + ttl`. Insert still persists (durable, required). 2. keepalive() ~L191: REPLACE box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}}) with deadlines[id] = clock.monotonic() + lease.ttl Keep the `box.space.leases:get(id)` existence check above it (cheap read, no fsync) — it supplies lease.ttl and preserves the NOT_FOUND-raises contract the gRPC stream handler depends on (see L169-171). 3. revoke() ~L150: after `box.space.leases:delete(id)`, clear `deadlines[id] = nil`. 4. time_to_live() ~L236: REPLACE `lease.expiry_time` read with `deadlines[id]`. If deadlines[id] is nil (lease loaded but not yet rebuilt), fall back to `clock.monotonic() + lease.granted_ttl`. remaining = max(0, floor(dl - now)). 5. expiry_loop() L287-338: REPLACE the `box.space.leases.index.expiry_time` scan (L302-309) with a scan of `deadlines`: collect ids where `deadline <= now`. Plain full-table scan is fine (numeric compares in Lua, runs every 500ms); a min-heap (etcd's leaseExpiredNotifier shape) is the optional optimization if lease counts get large. revoke() already nils the map entry on success. === REBUILD ON PROMOTE === Add lease.M.rebuild_deadlines(): clears `deadlines`, then for each tuple in box.space.leases:pairs() sets deadlines[tuple.id] = clock.monotonic() + tuple.ttl Call it in the RW branch of the box.watch('box.status', ...) callback in app/roles/etcd.lua (L341-353), immediately before lease.start_expiry(). This resets every inherited lease's deadline to a full-TTL grace period on the new leader — exactly etcd's failover behavior. === LATENT BUG THIS ALSO FIXES === expiry_time is currently computed as clock.monotonic()+ttl and REPLICATED. clock.monotonic() is process-local (relative to boot), so the persisted value is meaningless on any other node. Today a promoted replica's fiber scans inherited expiry_time values from a different monotonic timeline — could expire leases instantly or never. Rebuild-on-promote with the new leader's own clock removes this entirely. · 2026-05-20T07:39:02Z
  • Eugene Blikh updated acceptance_criteria to - keepalive issues ZERO WAL writes: box.info.lsn delta == 0 across N successive M.keepalive() calls on the same lease. - BenchmarkLeaseKeepAlive/n_* on btrfs SSD (work.lab.local, real-disk run) recovers from 207 ops/s to within ~2x of etcd (~4000+ ops/s, near the tmpfs baseline). Re-run: TMPDIR=$HOME/bench-data go test -run=^$ \ -bench=^BenchmarkLeaseKeepAlive$ -benchtime=5s ./bench/ - Lease still expires correctly: grant short TTL, no keep-alive, attached keys are deleted by the fiber after TTL elapses; keep-alive before TTL prevents it. - TimeToLive reflects the latest keep-alive (remaining resets to ~ttl after a renew). - Promote rebuilds deadlines: a freshly-promoted leader expires inherited leases using its own monotonic clock (no instant-expiry, no never-expiry). - All existing lease conformance cells (tarantool, tarantool_json, tarantool_rs3) and Lua lease tests pass., notes to TESTS (add Lua-side tests pinning the fix per the project's regression rule): - no-WAL: assert box.info.lsn unchanged across keepalive calls. - liveness: keepalive refreshes deadline (TimeToLive resets, lease survives). - promote rebuild: use the box.cfg{read_only=true}...{read_only=false}+ box.ctl.promote() in-process follower-simulation pattern (see role_test.lua group role/write_gates_in_process) to verify rebuild_deadlines repopulates. - cross-node: optionally exercise via the replicaset harness that a promoted replica expires an inherited lease. Conformance already covers cross-wire correctness; this is about the Lua unit pins + the bench recovery number. DOCS: update docs/BENCH.md Linux x86_64 disk section once re-benched; remove the LeaseKeepAlive "bug" annotation from the headline table. SCOPE NOTE: leave the expiry_time field + index in place (no migration). Only the keepalive write path and the fiber's liveness source change. · 2026-05-20T07:39:22Z
  • Eugene Blikh added under epic tarantool-etcd-95d · 2026-05-20T09:56:06Z
  • Eugene Blikh claimed · 2026-05-20T15:46:48Z
  • Eugene Blikh closed the issue · 2026-05-20T15:53:33Z
    keep-alive now bumps a leader-local in-memory deadline map (zero WAL on renew); leases space written only on grant/revoke; rebuild_deadlines on promote. Lua regression tests + TestLease conformance pass. Disk re-bench on btrfs SSD still pending to replace pre-fix BENCH.md numbers.
Stored rows — what this pane was built from, as read
issues 1 row
id tarantool-etcd-3sp
content_hash d73f28d5913d8a498b9e6a563791b85cc55ae47cfd49f87852ec3b9c8a37e6b2
title LeaseKeepAlive fsyncs WAL on every renew
description On btrfs SSD bench (2026-05-19 disk run), LeaseKeepAlive/n_X drops from 4 234 ops/s (tmpfs) to 207 ops/s on tarantool while etcd holds at ~7 400 ops/s in both configs. p50 goes from 224 µs (tmpfs) to 4 548 µs (disk) — exactly one btrfs fsync. Root cause at app/etcd/lease.lua:191: box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}}) The leases space is is_sync=true so every keep-alive does a full WAL append + fsync. Etcd's KeepAlive is an in-memory TTL bump with no disk write. Fix sketch: split expiry into an in-memory map (refreshed on every KeepAlive) and persist only on grant/revoke. The expiry fiber should read from the in-memory map. Trade-off: leases may extend further into the future than the on-disk record after a crash, which is fine — clients have to re-keep-alive after reconnect anyway. See docs/BENCH.md Linux x86_64 disk section (Open follow-ups).
design GOAL: lease keep-alive must not touch the WAL. Mirror etcd's split — durable state = {lease record, granted TTL, attached keys}; ephemeral leader-local state = the expiry deadline (refreshed on every keep-alive, never persisted). === STATE MODEL === Add a module-level in-memory map in app/etcd/lease.lua: local deadlines = {} -- [lease_id] = monotonic deadline (clock.monotonic()+ttl) This is leader-local. Followers never keep-alive (the gRPC write gate routes LeaseKeepAlive to the leader), and only the leader runs the expiry fiber and can revoke. So the countdown is structurally a leader concern. The `leases` space (schema.lua:97-118) stays is_sync=true and keeps its {id, ttl, expiry_time, granted_ttl} format. The `expiry_time` FIELD and its TREE index (schema.lua:113) become vestigial — written once at grant, never the source of liveness again. Leave them to avoid a space migration; an optional follow-up can drop the field+index. Nothing reads expiry_time after this change. === HANDLER CHANGES (app/etcd/lease.lua) === 1. grant() ~L70: after `box.space.leases:insert(...)`, seed `deadlines[id] = now + ttl`. Insert still persists (durable, required). 2. keepalive() ~L191: REPLACE box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}}) with deadlines[id] = clock.monotonic() + lease.ttl Keep the `box.space.leases:get(id)` existence check above it (cheap read, no fsync) — it supplies lease.ttl and preserves the NOT_FOUND-raises contract the gRPC stream handler depends on (see L169-171). 3. revoke() ~L150: after `box.space.leases:delete(id)`, clear `deadlines[id] = nil`. 4. time_to_live() ~L236: REPLACE `lease.expiry_time` read with `deadlines[id]`. If deadlines[id] is nil (lease loaded but not yet rebuilt), fall back to `clock.monotonic() + lease.granted_ttl`. remaining = max(0, floor(dl - now)). 5. expiry_loop() L287-338: REPLACE the `box.space.leases.index.expiry_time` scan (L302-309) with a scan of `deadlines`: collect ids where `deadline <= now`. Plain full-table scan is fine (numeric compares in Lua, runs every 500ms); a min-heap (etcd's leaseExpiredNotifier shape) is the optional optimization if lease counts get large. revoke() already nils the map entry on success. === REBUILD ON PROMOTE === Add lease.M.rebuild_deadlines(): clears `deadlines`, then for each tuple in box.space.leases:pairs() sets deadlines[tuple.id] = clock.monotonic() + tuple.ttl Call it in the RW branch of the box.watch('box.status', ...) callback in app/roles/etcd.lua (L341-353), immediately before lease.start_expiry(). This resets every inherited lease's deadline to a full-TTL grace period on the new leader — exactly etcd's failover behavior. === LATENT BUG THIS ALSO FIXES === expiry_time is currently computed as clock.monotonic()+ttl and REPLICATED. clock.monotonic() is process-local (relative to boot), so the persisted value is meaningless on any other node. Today a promoted replica's fiber scans inherited expiry_time values from a different monotonic timeline — could expire leases instantly or never. Rebuild-on-promote with the new leader's own clock removes this entirely.
acceptance_criteria - keepalive issues ZERO WAL writes: box.info.lsn delta == 0 across N successive M.keepalive() calls on the same lease. - BenchmarkLeaseKeepAlive/n_* on btrfs SSD (work.lab.local, real-disk run) recovers from 207 ops/s to within ~2x of etcd (~4000+ ops/s, near the tmpfs baseline). Re-run: TMPDIR=$HOME/bench-data go test -run=^$ \ -bench=^BenchmarkLeaseKeepAlive$ -benchtime=5s ./bench/ - Lease still expires correctly: grant short TTL, no keep-alive, attached keys are deleted by the fiber after TTL elapses; keep-alive before TTL prevents it. - TimeToLive reflects the latest keep-alive (remaining resets to ~ttl after a renew). - Promote rebuilds deadlines: a freshly-promoted leader expires inherited leases using its own monotonic clock (no instant-expiry, no never-expiry). - All existing lease conformance cells (tarantool, tarantool_json, tarantool_rs3) and Lua lease tests pass.
notes TESTS (add Lua-side tests pinning the fix per the project's regression rule): - no-WAL: assert box.info.lsn unchanged across keepalive calls. - liveness: keepalive refreshes deadline (TimeToLive resets, lease survives). - promote rebuild: use the box.cfg{read_only=true}...{read_only=false}+ box.ctl.promote() in-process follower-simulation pattern (see role_test.lua group role/write_gates_in_process) to verify rebuild_deadlines repopulates. - cross-node: optionally exercise via the replicaset harness that a promoted replica expires an inherited lease. Conformance already covers cross-wire correctness; this is about the Lua unit pins + the bench recovery number. DOCS: update docs/BENCH.md Linux x86_64 disk section once re-benched; remove the LeaseKeepAlive "bug" annotation from the headline table. SCOPE NOTE: leave the expiry_time field + index in place (no migration). Only the keepalive write path and the fiber's liveness source change.
status closed
priority 2
issue_type bug
assignee Eugene Blikh
estimated_minutes NULL
created_at 2026-05-19T17:33:47Z
created_by Eugene Blikh
owner bigbes@gmail.com
updated_at 2026-05-20T12:53:33Z
closed_at 2026-05-20T12:53:33Z
closed_by_session
external_ref NULL
spec_id
compaction_level 0
compacted_at NULL
compacted_at_commit NULL
original_size NULL
sender
ephemeral 0
wisp_type
pinned 0
is_template 0
mol_type
work_type
source_system
metadata <binary>
source_repo
close_reason keep-alive now bumps a leader-local in-memory deadline map (zero WAL on renew); leases space written only on grant/revoke; rebuild_deadlines on promote. Lua regression tests + TestLease conformance pass. Disk re-bench on btrfs SSD still pending to replace pre-fix BENCH.md numbers.
event_kind
actor
target
payload
await_type
await_id
timeout_ns 0
waiters
hook_bead
role_bead
agent_state
last_activity NULL
role_type
rig
due_at NULL
defer_until NULL
no_history 0
started_at 2026-05-20T12:46:48Z
is_blocked 0
dependencies 1 row
id 7482f01c-f4fe-592e-84df-a2b9860f28b5
issue_id tarantool-etcd-3sp
type parent-child
created_at 2026-05-20T09:56:06Z
created_by Eugene Blikh
metadata <binary>
thread_id
depends_on_issue_id tarantool-etcd-95d
depends_on_wisp_id NULL
depends_on_external NULL
events 5 rows
id 2e4bb86d-cb02-5103-b235-512b574a636c
issue_id tarantool-etcd-3sp
event_type updated
actor Eugene Blikh
old_value {"id":"tarantool-etcd-3sp","title":"LeaseKeepAlive fsyncs WAL on every renew","description":"On btrfs SSD bench (2026-05-19 disk run), LeaseKeepAlive/n_X drops from 4 234 ops/s (tmpfs) to 207 ops/s on tarantool while etcd holds at ~7 400 ops/s in both configs.\n\np50 goes from 224 µs (tmpfs) to 4 548 µs (disk) — exactly one btrfs fsync. Root cause at app/etcd/lease.lua:191:\n\n box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})\n\nThe leases space is is_sync=true so every keep-alive does a full WAL append + fsync. Etcd's KeepAlive is an in-memory TTL bump with no disk write.\n\nFix sketch: split expiry into an in-memory map (refreshed on every KeepAlive) and persist only on grant/revoke. The expiry fiber should read from the in-memory map. Trade-off: leases may extend further into the future than the on-disk record after a crash, which is fine — clients have to re-keep-alive after reconnect anyway.\n\nSee docs/BENCH.md Linux x86_64 disk section (Open follow-ups).","design":"GOAL: lease keep-alive must not touch the WAL. Mirror etcd's split — durable\nstate = {lease record, granted TTL, attached keys}; ephemeral leader-local\nstate = the expiry deadline (refreshed on every keep-alive, never persisted).\n\n=== STATE MODEL ===\nAdd a module-level in-memory map in app/etcd/lease.lua:\n local deadlines = {} -- [lease_id] = monotonic deadline (clock.monotonic()+ttl)\nThis is leader-local. Followers never keep-alive (the gRPC write gate routes\nLeaseKeepAlive to the leader), and only the leader runs the expiry fiber and\ncan revoke. So the countdown is structurally a leader concern.\n\nThe `leases` space (schema.lua:97-118) stays is_sync=true and keeps its\n{id, ttl, expiry_time, granted_ttl} format. The `expiry_time` FIELD and its\nTREE index (schema.lua:113) become vestigial — written once at grant, never\nthe source of liveness again. Leave them to avoid a space migration; an\noptional follow-up can drop the field+index. Nothing reads expiry_time after\nthis change.\n\n=== HANDLER CHANGES (app/etcd/lease.lua) ===\n1. grant() ~L70: after `box.space.leases:insert(...)`, seed\n `deadlines[id] = now + ttl`. Insert still persists (durable, required).\n2. keepalive() ~L191: REPLACE\n box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})\n with\n deadlines[id] = clock.monotonic() + lease.ttl\n Keep the `box.space.leases:get(id)` existence check above it (cheap read,\n no fsync) — it supplies lease.ttl and preserves the NOT_FOUND-raises\n contract the gRPC stream handler depends on (see L169-171).\n3. revoke() ~L150: after `box.space.leases:delete(id)`, clear\n `deadlines[id] = nil`.\n4. time_to_live() ~L236: REPLACE `lease.expiry_time` read with `deadlines[id]`.\n If deadlines[id] is nil (lease loaded but not yet rebuilt), fall back to\n `clock.monotonic() + lease.granted_ttl`. remaining = max(0, floor(dl - now)).\n5. expiry_loop() L287-338: REPLACE the `box.space.leases.index.expiry_time`\n scan (L302-309) with a scan of `deadlines`: collect ids where\n `deadline \u003c= now`. Plain full-table scan is fine (numeric compares in Lua,\n runs every 500ms); a min-heap (etcd's leaseExpiredNotifier shape) is the\n optional optimization if lease counts get large. revoke() already nils the\n map entry on success.\n\n=== REBUILD ON PROMOTE ===\nAdd lease.M.rebuild_deadlines():\n clears `deadlines`, then for each tuple in box.space.leases:pairs() sets\n deadlines[tuple.id] = clock.monotonic() + tuple.ttl\nCall it in the RW branch of the box.watch('box.status', ...) callback in\napp/roles/etcd.lua (L341-353), immediately before lease.start_expiry().\nThis resets every inherited lease's deadline to a full-TTL grace period on the\nnew leader — exactly etcd's failover behavior.\n\n=== LATENT BUG THIS ALSO FIXES ===\nexpiry_time is currently computed as clock.monotonic()+ttl and REPLICATED.\nclock.monotonic() is process-local (relative to boot), so the persisted value\nis meaningless on any other node. Today a promoted replica's fiber scans\ninherited expiry_time values from a different monotonic timeline — could\nexpire leases instantly or never. Rebuild-on-promote with the new leader's own\nclock removes this entirely.","status":"open","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-05-19T17:33:47Z","created_by":"Eugene Blikh","updated_at":"2026-05-20T04:39:03Z"}
new_value {"acceptance_criteria":"- keepalive issues ZERO WAL writes: box.info.lsn delta == 0 across N successive\n M.keepalive() calls on the same lease.\n- BenchmarkLeaseKeepAlive/n_* on btrfs SSD (work.lab.local, real-disk run)\n recovers from 207 ops/s to within ~2x of etcd (~4000+ ops/s, near the tmpfs\n baseline). Re-run: TMPDIR=$HOME/bench-data go test -run=^$ \\\n -bench=^BenchmarkLeaseKeepAlive$ -benchtime=5s ./bench/\n- Lease still expires correctly: grant short TTL, no keep-alive, attached keys\n are deleted by the fiber after TTL elapses; keep-alive before TTL prevents it.\n- TimeToLive reflects the latest keep-alive (remaining resets to ~ttl after a\n renew).\n- Promote rebuilds deadlines: a freshly-promoted leader expires inherited\n leases using its own monotonic clock (no instant-expiry, no never-expiry).\n- All existing lease conformance cells (tarantool, tarantool_json,\n tarantool_rs3) and Lua lease tests pass.","notes":"TESTS (add Lua-side tests pinning the fix per the project's regression rule):\n- no-WAL: assert box.info.lsn unchanged across keepalive calls.\n- liveness: keepalive refreshes deadline (TimeToLive resets, lease survives).\n- promote rebuild: use the box.cfg{read_only=true}...{read_only=false}+\n box.ctl.promote() in-process follower-simulation pattern (see role_test.lua\n group role/write_gates_in_process) to verify rebuild_deadlines repopulates.\n- cross-node: optionally exercise via the replicaset harness that a promoted\n replica expires an inherited lease.\nConformance already covers cross-wire correctness; this is about the Lua unit\npins + the bench recovery number.\n\nDOCS: update docs/BENCH.md Linux x86_64 disk section once re-benched; remove\nthe LeaseKeepAlive \"bug\" annotation from the headline table.\n\nSCOPE NOTE: leave the expiry_time field + index in place (no migration). Only\nthe keepalive write path and the fiber's liveness source change."}
comment NULL
created_at 2026-05-20T07:39:22Z
id 64e46991-9d5f-5497-bb5a-b1746803168d
issue_id tarantool-etcd-3sp
event_type closed
actor Eugene Blikh
old_value
new_value keep-alive now bumps a leader-local in-memory deadline map (zero WAL on renew); leases space written only on grant/revoke; rebuild_deadlines on promote. Lua regression tests + TestLease conformance pass. Disk re-bench on btrfs SSD still pending to replace pre-fix BENCH.md numbers.
comment NULL
created_at 2026-05-20T15:53:33Z
id 9c78d1b3-2262-522e-a6d3-f20350d2d32f
issue_id tarantool-etcd-3sp
event_type created
actor Eugene Blikh
old_value
new_value
comment NULL
created_at 2026-05-19T20:33:47Z
id b626f9f2-b874-5719-bb14-2ce0d8ae9e87
issue_id tarantool-etcd-3sp
event_type updated
actor Eugene Blikh
old_value {"id":"tarantool-etcd-3sp","title":"LeaseKeepAlive fsyncs WAL on every renew","description":"On btrfs SSD bench (2026-05-19 disk run), LeaseKeepAlive/n_X drops from 4 234 ops/s (tmpfs) to 207 ops/s on tarantool while etcd holds at ~7 400 ops/s in both configs.\n\np50 goes from 224 µs (tmpfs) to 4 548 µs (disk) — exactly one btrfs fsync. Root cause at app/etcd/lease.lua:191:\n\n box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})\n\nThe leases space is is_sync=true so every keep-alive does a full WAL append + fsync. Etcd's KeepAlive is an in-memory TTL bump with no disk write.\n\nFix sketch: split expiry into an in-memory map (refreshed on every KeepAlive) and persist only on grant/revoke. The expiry fiber should read from the in-memory map. Trade-off: leases may extend further into the future than the on-disk record after a crash, which is fine — clients have to re-keep-alive after reconnect anyway.\n\nSee docs/BENCH.md Linux x86_64 disk section (Open follow-ups).","status":"open","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-05-19T17:33:47Z","created_by":"Eugene Blikh","updated_at":"2026-05-19T17:33:47Z"}
new_value {"design":"GOAL: lease keep-alive must not touch the WAL. Mirror etcd's split — durable\nstate = {lease record, granted TTL, attached keys}; ephemeral leader-local\nstate = the expiry deadline (refreshed on every keep-alive, never persisted).\n\n=== STATE MODEL ===\nAdd a module-level in-memory map in app/etcd/lease.lua:\n local deadlines = {} -- [lease_id] = monotonic deadline (clock.monotonic()+ttl)\nThis is leader-local. Followers never keep-alive (the gRPC write gate routes\nLeaseKeepAlive to the leader), and only the leader runs the expiry fiber and\ncan revoke. So the countdown is structurally a leader concern.\n\nThe `leases` space (schema.lua:97-118) stays is_sync=true and keeps its\n{id, ttl, expiry_time, granted_ttl} format. The `expiry_time` FIELD and its\nTREE index (schema.lua:113) become vestigial — written once at grant, never\nthe source of liveness again. Leave them to avoid a space migration; an\noptional follow-up can drop the field+index. Nothing reads expiry_time after\nthis change.\n\n=== HANDLER CHANGES (app/etcd/lease.lua) ===\n1. grant() ~L70: after `box.space.leases:insert(...)`, seed\n `deadlines[id] = now + ttl`. Insert still persists (durable, required).\n2. keepalive() ~L191: REPLACE\n box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})\n with\n deadlines[id] = clock.monotonic() + lease.ttl\n Keep the `box.space.leases:get(id)` existence check above it (cheap read,\n no fsync) — it supplies lease.ttl and preserves the NOT_FOUND-raises\n contract the gRPC stream handler depends on (see L169-171).\n3. revoke() ~L150: after `box.space.leases:delete(id)`, clear\n `deadlines[id] = nil`.\n4. time_to_live() ~L236: REPLACE `lease.expiry_time` read with `deadlines[id]`.\n If deadlines[id] is nil (lease loaded but not yet rebuilt), fall back to\n `clock.monotonic() + lease.granted_ttl`. remaining = max(0, floor(dl - now)).\n5. expiry_loop() L287-338: REPLACE the `box.space.leases.index.expiry_time`\n scan (L302-309) with a scan of `deadlines`: collect ids where\n `deadline \u003c= now`. Plain full-table scan is fine (numeric compares in Lua,\n runs every 500ms); a min-heap (etcd's leaseExpiredNotifier shape) is the\n optional optimization if lease counts get large. revoke() already nils the\n map entry on success.\n\n=== REBUILD ON PROMOTE ===\nAdd lease.M.rebuild_deadlines():\n clears `deadlines`, then for each tuple in box.space.leases:pairs() sets\n deadlines[tuple.id] = clock.monotonic() + tuple.ttl\nCall it in the RW branch of the box.watch('box.status', ...) callback in\napp/roles/etcd.lua (L341-353), immediately before lease.start_expiry().\nThis resets every inherited lease's deadline to a full-TTL grace period on the\nnew leader — exactly etcd's failover behavior.\n\n=== LATENT BUG THIS ALSO FIXES ===\nexpiry_time is currently computed as clock.monotonic()+ttl and REPLICATED.\nclock.monotonic() is process-local (relative to boot), so the persisted value\nis meaningless on any other node. Today a promoted replica's fiber scans\ninherited expiry_time values from a different monotonic timeline — could\nexpire leases instantly or never. Rebuild-on-promote with the new leader's own\nclock removes this entirely."}
comment NULL
created_at 2026-05-20T07:39:02Z
id e2ea8dd7-f559-5778-8039-5ac01d6c9c02
issue_id tarantool-etcd-3sp
event_type claimed
actor Eugene Blikh
old_value {"id":"tarantool-etcd-3sp","title":"LeaseKeepAlive fsyncs WAL on every renew","description":"On btrfs SSD bench (2026-05-19 disk run), LeaseKeepAlive/n_X drops from 4 234 ops/s (tmpfs) to 207 ops/s on tarantool while etcd holds at ~7 400 ops/s in both configs.\n\np50 goes from 224 µs (tmpfs) to 4 548 µs (disk) — exactly one btrfs fsync. Root cause at app/etcd/lease.lua:191:\n\n box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})\n\nThe leases space is is_sync=true so every keep-alive does a full WAL append + fsync. Etcd's KeepAlive is an in-memory TTL bump with no disk write.\n\nFix sketch: split expiry into an in-memory map (refreshed on every KeepAlive) and persist only on grant/revoke. The expiry fiber should read from the in-memory map. Trade-off: leases may extend further into the future than the on-disk record after a crash, which is fine — clients have to re-keep-alive after reconnect anyway.\n\nSee docs/BENCH.md Linux x86_64 disk section (Open follow-ups).","design":"GOAL: lease keep-alive must not touch the WAL. Mirror etcd's split — durable\nstate = {lease record, granted TTL, attached keys}; ephemeral leader-local\nstate = the expiry deadline (refreshed on every keep-alive, never persisted).\n\n=== STATE MODEL ===\nAdd a module-level in-memory map in app/etcd/lease.lua:\n local deadlines = {} -- [lease_id] = monotonic deadline (clock.monotonic()+ttl)\nThis is leader-local. Followers never keep-alive (the gRPC write gate routes\nLeaseKeepAlive to the leader), and only the leader runs the expiry fiber and\ncan revoke. So the countdown is structurally a leader concern.\n\nThe `leases` space (schema.lua:97-118) stays is_sync=true and keeps its\n{id, ttl, expiry_time, granted_ttl} format. The `expiry_time` FIELD and its\nTREE index (schema.lua:113) become vestigial — written once at grant, never\nthe source of liveness again. Leave them to avoid a space migration; an\noptional follow-up can drop the field+index. Nothing reads expiry_time after\nthis change.\n\n=== HANDLER CHANGES (app/etcd/lease.lua) ===\n1. grant() ~L70: after `box.space.leases:insert(...)`, seed\n `deadlines[id] = now + ttl`. Insert still persists (durable, required).\n2. keepalive() ~L191: REPLACE\n box.space.leases:update(id, {{'=', 'expiry_time', now + lease.ttl}})\n with\n deadlines[id] = clock.monotonic() + lease.ttl\n Keep the `box.space.leases:get(id)` existence check above it (cheap read,\n no fsync) — it supplies lease.ttl and preserves the NOT_FOUND-raises\n contract the gRPC stream handler depends on (see L169-171).\n3. revoke() ~L150: after `box.space.leases:delete(id)`, clear\n `deadlines[id] = nil`.\n4. time_to_live() ~L236: REPLACE `lease.expiry_time` read with `deadlines[id]`.\n If deadlines[id] is nil (lease loaded but not yet rebuilt), fall back to\n `clock.monotonic() + lease.granted_ttl`. remaining = max(0, floor(dl - now)).\n5. expiry_loop() L287-338: REPLACE the `box.space.leases.index.expiry_time`\n scan (L302-309) with a scan of `deadlines`: collect ids where\n `deadline \u003c= now`. Plain full-table scan is fine (numeric compares in Lua,\n runs every 500ms); a min-heap (etcd's leaseExpiredNotifier shape) is the\n optional optimization if lease counts get large. revoke() already nils the\n map entry on success.\n\n=== REBUILD ON PROMOTE ===\nAdd lease.M.rebuild_deadlines():\n clears `deadlines`, then for each tuple in box.space.leases:pairs() sets\n deadlines[tuple.id] = clock.monotonic() + tuple.ttl\nCall it in the RW branch of the box.watch('box.status', ...) callback in\napp/roles/etcd.lua (L341-353), immediately before lease.start_expiry().\nThis resets every inherited lease's deadline to a full-TTL grace period on the\nnew leader — exactly etcd's failover behavior.\n\n=== LATENT BUG THIS ALSO FIXES ===\nexpiry_time is currently computed as clock.monotonic()+ttl and REPLICATED.\nclock.monotonic() is process-local (relative to boot), so the persisted value\nis meaningless on any other node. Today a promoted replica's fiber scans\ninherited expiry_time values from a different monotonic timeline — could\nexpire leases instantly or never. Rebuild-on-promote with the new leader's own\nclock removes this entirely.","acceptance_criteria":"- keepalive issues ZERO WAL writes: box.info.lsn delta == 0 across N successive\n M.keepalive() calls on the same lease.\n- BenchmarkLeaseKeepAlive/n_* on btrfs SSD (work.lab.local, real-disk run)\n recovers from 207 ops/s to within ~2x of etcd (~4000+ ops/s, near the tmpfs\n baseline). Re-run: TMPDIR=$HOME/bench-data go test -run=^$ \\\n -bench=^BenchmarkLeaseKeepAlive$ -benchtime=5s ./bench/\n- Lease still expires correctly: grant short TTL, no keep-alive, attached keys\n are deleted by the fiber after TTL elapses; keep-alive before TTL prevents it.\n- TimeToLive reflects the latest keep-alive (remaining resets to ~ttl after a\n renew).\n- Promote rebuilds deadlines: a freshly-promoted leader expires inherited\n leases using its own monotonic clock (no instant-expiry, no never-expiry).\n- All existing lease conformance cells (tarantool, tarantool_json,\n tarantool_rs3) and Lua lease tests pass.","notes":"TESTS (add Lua-side tests pinning the fix per the project's regression rule):\n- no-WAL: assert box.info.lsn unchanged across keepalive calls.\n- liveness: keepalive refreshes deadline (TimeToLive resets, lease survives).\n- promote rebuild: use the box.cfg{read_only=true}...{read_only=false}+\n box.ctl.promote() in-process follower-simulation pattern (see role_test.lua\n group role/write_gates_in_process) to verify rebuild_deadlines repopulates.\n- cross-node: optionally exercise via the replicaset harness that a promoted\n replica expires an inherited lease.\nConformance already covers cross-wire correctness; this is about the Lua unit\npins + the bench recovery number.\n\nDOCS: update docs/BENCH.md Linux x86_64 disk section once re-benched; remove\nthe LeaseKeepAlive \"bug\" annotation from the headline table.\n\nSCOPE NOTE: leave the expiry_time field + index in place (no migration). Only\nthe keepalive write path and the fiber's liveness source change.","status":"open","priority":2,"issue_type":"bug","owner":"bigbes@gmail.com","created_at":"2026-05-19T17:33:47Z","created_by":"Eugene Blikh","updated_at":"2026-05-20T04:39:23Z"}
new_value {"assignee":"Eugene Blikh","status":"in_progress"}
comment NULL
created_at 2026-05-20T15:46:48Z