TD-6376: Improve lease management: fix lease races and rework rebalancing to per-worker - #49
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. |
|
Heads-up: PR #50 (TD-6395) is landing a fix for the recurring The bug: Interaction with this PR:
PR #50's approach: give checkpoint its own No conflict expected at the file level (PR #50 doesn't touch the producer or |
… caller
Producer.handle_call(:start, ...) fetched records before replying, and
Producer.start/1 is a GenServer.call/2 with the default 5s timeout. The fetch
runs behind `@retry with: 500 |> exponential_backoff() |> Stream.take(5)`, so
~15s is reachable on a transient Kinesis error — well past the caller's
deadline.
The caller is LeaseV2, via Pipeline.start/1, on the take and steal paths. When
the call timed out, the *caller* died: a lease that had just been acquired
crashed its LeaseV2 process, dropped lease_holder, restarted, re-acquired, and
timed out again. Observed on assessment_service staging as a shard that never
stayed leased long enough to consume anything:
GenServer :"...LeaseV2....shardId-000000012266" terminating
** (stop) exited in: GenServer.call(:"...Producer_0", :start, 5000)
** (EXIT) time out
pipeline.ex:75: KinesisClient.Stream.Shard.Pipeline.start/1
lease_v2.ex:253: KinesisClient.Stream.Shard.LeaseV2.steal_shard_lease/2
The reply is unconditionally :ok — every branch of the case replies the same —
and LeaseV2 discards it, so replying first is behaviour-preserving. It also
composes badly with a backlog: the longer a stream sits unconsumed, the slower
the first GetRecords, the more likely the timeout, the less able it is to
recover on its own.
Sending the reply first does not make the producer any less busy; it just stops
that work from being attributed to the caller's deadline.
Also updates PipelineTest "can start producer": it used the :start reply as its
signal that the fetch had happened, which is no longer true, so it now waits on
the get_lease calls themselves. The `2 times` expectation is unchanged.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
|
Ran the full 1. Boot-time race:
|
…ace logs Three fixes from the kcl_test harness review on this PR (all no-data-impact): 1. Boot-time race: Shard supervises [LeaseV2, Pipeline] :one_for_all with the lease process first, so a lease won during LeaseV2.handle_continue could call Pipeline.start/1 before Broadway finished registering, exiting :noproc and crashing the lease process on essentially every fresh deploy of a first/lone worker (it self-heals via restart, but logs a scary exit). Pipeline.start/1 now retries on the :noproc exit with a bounded backoff (25 x 100ms); Broadway is a supervised sibling that comes up within a tick. 2. Expiry-take didn't skip completed shards: a completed shard's owner stops renewing, so the lease looks expired forever and another worker's idle LeaseV2 would take it — flipping lease_owner on a completed=true row. Wasted write + confusing churn. take_or_renew_lease now guards the expiry-take branch with `not shard_lease.completed` and falls through to tracking. 3. Lost take/steal races logged at :error: losing the optimistic-lock race is expected on every scale-out/failover, and it produced a burst of [error] lines per scale event that read as alarming on dashboards. Downgraded the lost-race paths to :warning in LeaseV2 (take + steal) and the Ecto adapter's take_lease. Genuine adapter failures raise, so :error still surfaces those. Adds a regression test that a completed, expired lease is tracked, not taken. Full non-integration suite: 87 tests, 0 failures.
|
Addressed all three observations from the harness runs in 1. Boot-time race ( 2. Expiry-take doesn't skip 3. Lost take/steal races log at Full non-integration suite: 87 tests, 0 failures. Didn't touch the load-balancing / steal-convergence logic, since the runs showed it converging cleanly (0 missing / 0 dupes across all scenarios). |
take_shard_lease/2 passed state.lease_count to AppState.take_lease/6 while
steal_shard_lease/2 correctly passed the freshly read shard_lease.lease_count.
state.lease_count is only synced on the renew tick, so for a non-holder it
lags every renewal the current owner performs.
Both adapters look the lease row up by lease_count, so a stale value matches
no row: the Ecto adapter returns {:error, :not_found}, surfaced as
:lease_take_failed. Take-on-expiry is the path that transfers a lease from a
terminated worker, so this made those leases unclaimable by anyone — after a
rolling deploy no pod could acquire a lease and the stream stopped being
consumed. It is silent: the failures log at :warning and nothing crashes.
Observed on staging (assessment_service on this branch): 42 take attempts,
0 successes, every one :not_found, no worker holding a lease and no shard
polling once the previous pods had terminated.
`expected` is derived from shard_lease.lease_count for the same reason —
take_lease reports the count it was given plus one, so leaving `expected`
on state.lease_count would make a successful take fall through the
{:ok, ^expected} clause and raise CaseClauseError.
The existing "takes lease if lease_expiry exceeded" test stubs a single
shard_lease, so state.lease_count and the fresh count are always equal and
the bug is invisible. The added regression test makes them diverge (init
reads 12, owner renews to 13) and fails without this change with lc == 12.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
…tention"
Ecto.take_lease/6 collapsed every failure into one warning labelled
"(lease contention)", and LeaseV2 labelled the same failures "(another worker
won)". Only one of the three outcomes is contention:
:update_unsuccessful the row was found but lease_count/lease_owner changed
between read and conditional update — a genuinely lost
optimistic-lock race, expected on scale-out/failover
:not_found no row matched (shard_id, app_name, stream_name,
lease_count). The lookup is keyed on lease_count, so
this is a stale or wrong lease_count from the caller,
or a missing row. Not contention, and not something a
retry fixes.
:lease_owner_match this worker already owns the lease — nothing to take,
so :debug rather than :warning
This mattered in practice: the stale-lease_count bug fixed in the previous
commit surfaced only as :not_found, and reading it as "lease contention" /
"another worker won" made a total loss of lease acquisition look like ordinary
deploy-time churn for the first twenty minutes of triage.
Return values are unchanged — all paths still yield {:error, :lease_take_failed}.
KinesisClient.Stream.Shard.Lease matches that atom specifically and would fall
through to its catch-all error clause on anything else, and the Dynamo adapter
and adapter behaviour spec use the same value.
Not covered by tests: test/support/ecto_repo.ex fakes the repo with one/1
returning a canned ShardLease and update_all/2 always returning {1, [_]}, so
neither the lookup filters nor the conditional-update WHERE clause are
exercised and :not_found is unreachable in tests. Noted on the PR.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
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