main · last commit
1 month ago ·
2npkec0r
tarantool-etcd-3sp LeaseKeepAlive fsyncs WAL on every renew
Past Stand
bd reopen tarantool-etcd-3sp
| Created by | Eugene Blikh |
| Owner | bigbes@gmail.com |
| Created | 2026-05-19T17:33:47Z |
| Started | 2026-05-20T12:46:48Z |
| Updated | 2026-05-20T12:53:33Z |
| Closed | 2026-05-20T12:53:33Z |
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).
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.
- 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.
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.
tarantool-etcd-95d
— [epic] Performance
parent-child
open
Nothing depends on this issue.
| 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 |
| 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 |
| 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 |
No comments.
Close reason