TD-6376: Improve lease management: fix lease races and rework rebalancing to per-worker#49
TD-6376: Improve lease management: fix lease races and rework rebalancing to per-worker#49tkusumo wants to merge 4 commits into
Conversation
Lease correctness fixes: - Stop the pipeline and release lease_holder when a lease renewal fails (was silently continuing to consume, risking duplicate processing) - Steal with the freshly read lease_count instead of the stale state copy, which failed the optimistic lock on every steal attempt - Pin lease_owner (not just lease_count) in the Ecto update WHERE clause, matching the Dynamo adapter's conditional expressions - Count workers holding zero leases in the balance check so new nodes actually claim their share of shards Dynamo adapter: - Implement the load balancing queries (get_leases_by_worker, all_incomplete_leases, total_incomplete_lease_counts_by_worker) with filtered scans; previously they raised or silently returned [] Rebalancing redesign: - New per-worker KinesisClient.Stream.Rebalancer replaces the per-shard rebalance tick: one lease-count query per worker per tick instead of 3-5 queries per shard per tick - Steals are capped (max_leases_to_steal, default 1) and the tick is jittered +/- 25%, so workers converge without stampeding or flapping - LoadBalance is now pure decision logic with a flip-flop guard (no steal when the lead is a single lease) - LeaseV2 executes steals on :steal_lease messages, staying the single writer for its shard; drops its balancing logic and rebalance timer - Remove the now-unused lease_owner_with_most_leases adapter callback - Use the injected pipeline module consistently in LeaseV2 Also documents the current lease options and load balancing behavior in the README (lease_renewal_limit was a dead V1-only option). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…teals - Reclaim a lease the AppState names us as owner of while lease_holder is false (e.g. a renewal that "failed" after actually being applied via a lost-response retry). Previously this state was unrecoverable: renewing requires lease_holder and both adapters reject taking a lease you already own, so the shard sat unconsumed forever. - Rescue the rebalance tick: adapters raise on transient failures (throttled Dynamo scan, DB blip) and the Rebalancer shares a :one_for_all supervisor with the Coordinator and every pipeline — a failed best-effort balance check must not tear down the data path. - Clamp steals to the worker's lease deficit: LoadBalance.decide/2 now returns the deficit and steal_from takes min(deficit, max_leases_to_steal), so max_leases_to_steal > 1 no longer overshoots the target and oscillates. - Carry the chosen victim in the :steal_lease message and skip the steal if the lease changed owners since the decision. - Delegate Mimic.get_leases_by_worker to the migration adapters instead of hard-coding [] (rebalancing silently no-oped during migrations). - Expose LeaseV2.whereis/3 instead of re-deriving the registration name in the Rebalancer; guard jitter/1 against sub-2ms intervals; drop the dead :spread_lease option and the misleading :global comment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
Addressed the review in b9d44b5. Point-by-point: Fix before merge — both addressed:
Should fix — all three done:
Also picked up from the smaller notes: Deferred (agree they're not blockers): steal-at-renewal-boundary to close the ~30s double-consumption window, Dynamo scan efficiency (projection + reusing one scan per tick), and deduplicating 84 tests, 0 failures across repeated runs (16 invalid = the localstack-dependent Dynamo tests, unchanged). |
…up notify - Stop the Producer as soon as an owner-guarded checkpoint fails against a lease owned by another worker. Record fetches were already owner-checked per poll, so this closes the remaining post-steal overlap: the victim now halts at its first checkpoint after the steal instead of idling until the lease process's next renewal. - One AppState query per rebalance tick: the Rebalancer fetches the incomplete leases once and derives both the per-worker counts and the steal candidates from that list, instead of a counts query plus a second full scan on unbalanced ticks. Removes the now-unused total_incomplete_lease_counts_by_worker callback from the behaviour, facade, and all three adapters. - Move the triplicated notify/2 test hook into KinesisClient.Util. - Rename Producer.is_lease_owner? to lease_owner? per naming standards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
The three deferred items are now in this PR as well (c289ee9): Double-consumption window: on inspection, the fetch path was already owner-guarded — Dynamo scan efficiency: went with your "one scan per tick" suggestion, which supersedes the projection idea (a
Net for the PR: |
The deficit alone bounds what the thief needs, not what the victim can
give up: with max_leases_to_steal >= 2, {2, 4, 4} stole the deficit of 2
and flipped the pair to {4, 2, 4} every round, forever. The steal count
is now min(target - my_count, div(victim_count - my_count, 2)) — half
the gap is the largest move that cannot invert the pairwise imbalance.
Verified with a simulation against the real decide/2: 2500 randomized
states (2-7 workers, max_leases_to_steal 1-8, shuffled per-round worker
orderings) plus the reported {2,4,4} and {5,5,0} counter-examples all
converge to balanced.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
|
Gap clamp added in 4e1587a — confirmed your counter-example by hand first ({2, 4, 4}: deficit 2, but taking 2 flips the A↔B gap from −2 to +2 and the pair trades the same leases every round).
Independently re-verified convergence with a simulation against the real Tests: the {2,4,4} live-lock is a named regression case, a {5,5,0} round-by-round convergence case is added, and the existing decide/2 expectations were updated for the tighter steal counts (e.g. {1,4,4} now steals 1, not 2). 85 tests, 0 failures. |
|
Reviewed the full change plus the three follow-up commits — the changes look good. Original review findings, all resolved and regression-tested:
Suite: 85 tests, 0 failures locally. The 15 invalid are the localstack-dependent Dynamo tests — worth confirming those pass in CI before merge, as the PR description notes. |
TD-6376
Summary
Fixes several correctness bugs in the lease subsystem and reworks lease rebalancing from a per-shard tick to a single per-worker
Rebalancerprocess.Correctness fixes
lease_holder. PreviouslyLeaseV2logged the error and kept consuming, so a shard whose lease was stolen or expired could be processed by two workers at once (a regression from V1's behavior).lease_countinstead of the copy in state, which was only synced on the 30s renew tick — a stale count failed the optimistic lock on every steal attempt for up to 30s.lease_owneralongsidelease_count, closing the read-then-update race and matching the Dynamo adapter's conditional expressions.Dynamo adapter
Implements the load balancing queries (
get_leases_by_worker,all_incomplete_leases,total_incomplete_lease_counts_by_worker) with filtered scans +ExAws.stream!pagination. Previously they raisedBadFunctionErroror silently returned[], which made V2 lease balancing a no-op on DynamoDB.Rebalancing redesign
KinesisClient.Stream.Rebalancer— one perStreamsupervisor (per worker), same shape as the Java KCL'sLeaseTaker. Each jittered tick (rebalance_interval± 25%) it makes one lease-count query and, when under-loaded, requests at mostmax_leases_to_steal(default 1) steals. Before: every shard's lease process ran 3-5 full-table queries every 6s and stole unboundedly in the same tick window (100 shards ≈ 5-6k queries/min, now ~tens/min).LoadBalanceis now pure decision logic (decide/2) with a flip-flop guard: no steal when the lead is a single lease (e.g. 7 shards on 2 workers can never be more even than 4/3).LeaseV2executes steals on:steal_leasemessages and stays the single writer for its shard's lease state; its rebalance timer and balancing logic are gone. Crash recovery (take-on-expiry) is unchanged.lease_owner_with_most_leasesadapter callback.Docs
README now documents the actual lease options (
lease_renew_interval,lease_expiry,rebalance_interval,max_leases_to_steal) and the load balancing behavior —lease_renewal_limitwas a dead V1-only option.Test plan
LoadBalance.decide/2unit tests, including a step-by-step convergence simulation of a{4, 4, 0}cluster.Rebalancertests: balanced → no steal; steal routed to the correct local lease process; per-tick cap respected; completed shards skipped.LeaseV2tests reworked around direct:steal_leasemessages, plus regression tests for the renewal-failure and stale-count bugs.coordinator_test"don't start child shard until parent shard is closed" previously asserted parent shard supervisors die — an artifact of the old init-time balancing crash-looping on an unstubbed mock. It now asserts parents stay alive; the real intent (children not started early) was always covered by itsrefute_receivelines.🤖 Generated with Claude Code