foundationdb/recipes/leader_election/
mod.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
// Copyright 2024 foundationdb-rs developers
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.

//! Poll-based Dynamo-style leader leases with FoundationDB fencing.
//!
//! This recipe adapts the client-side timing model of the
//! [Amazon DynamoDB lock client](https://aws.amazon.com/blogs/aws/new-amazon-dynamodb-lock-client/).
//! A caller observes a durable owner record and uses elapsed time from its own
//! monotonic clock to decide when that unchanged record is suspicious.
//! FoundationDB's serializable transaction on one state key, not local time,
//! resolves concurrent acquisition, renewal, reacquisition, and takeover.
//! The protocol is Dynamo-inspired, not wire-compatible with the DynamoDB lock
//! client.
//!
//! ## Durable state and local time
//!
//! Durable state contains an optional owner, a monotonically increasing
//! revision, and the last persisted relative lease duration. It contains no
//! wall-clock or monotonic-clock reading, last-renewed timestamp, deadline, or
//! expiry. A released record retains its revision and duration, while a
//! never-created record has revision zero and no duration.
//!
//! [`Leadership`](crate::recipes::leader_election::Leadership) and
//! [`Observation`](crate::recipes::leader_election::Observation) are caller-local
//! state. A leadership token may renew only while elapsed time on that caller's
//! clock is below the duration stored in its exact durable revision. A follower
//! waits the duration stored in its exact observation, never this handle's
//! configured duration.
//! If the observed owner, revision, or duration changes, the caller starts a
//! new observation window. If it is unchanged, the original observation time
//! is retained.
//!
//! Clocks are never persisted or compared across processes. They only measure
//! elapsed time for the caller that recorded them. On process restart, use a
//! fresh [`ParticipantId`](crate::recipes::leader_election::ParticipantId) and
//! begin again with
//! [`LocalState::Unknown`](crate::recipes::leader_election::LocalState::Unknown),
//! so the new incarnation observes the durable state and waits anew before
//! attempting takeover.
//!
//! ## Cutover from v0.11 durable state
//!
//! This release's durable state is intentionally incompatible with v0.11.
//! v0.11 and new clients use different keys, so a mixed deployment is unsafe:
//! each population can elect a leader without observing or fencing the other.
//!
//! Upgrade by a destructive cutover, not an in-place migration. Stop or
//! quiesce every old participant and all protected work before starting the
//! new deployment. Then allocate fresh subspaces or epochs for the new
//! election, its [`RankedRegister`](crate::recipes::ranked_register::RankedRegister),
//! and the protected sink or rank namespace. Only then start clients using
//! this release. This recipe neither reads nor migrates v0.11 durable state.
//!
//! ## Poll lifecycle
//!
//! Call [`LeaderElection::poll`](crate::recipes::leader_election::LeaderElection::poll)
//! inside the closure passed to
//! [`Database::run`](crate::Database::run). Read `attempt_started_at` from the
//! caller's monotonic clock immediately before each `poll` call, including
//! every retry attempt. It controls renewal and takeover eligibility and stamps
//! new leadership, so time spent reading, retrying, or committing only shortens
//! local validity.
//!
//! A returned [`PollResult`](crate::recipes::leader_election::PollResult) is
//! only prepared state. Adopt it with
//! [`PollResult::into_next_state`](crate::recipes::leader_election::PollResult::into_next_state)
//! after the outer `Database::run` succeeds. Use a fresh `adopted_at` reading
//! then: it starts timing for a new or reset observation only after its durable
//! read is known to have committed. An unchanged observation and a new
//! leadership token retain their original attempt-local times. This prevents
//! retries, cancellation, and unknown commits from authorizing work based on
//! uncommitted caller-local state.
//!
//! The poll transaction reads and, for a leadership transition, writes the
//! same durable key. FoundationDB conflict resolution serializes competing
//! transitions. Local time only permits an attempted conditional takeover; it
//! does not prove that the prior process stopped.
//!
//! ## Renewal cadence and fencing epochs
//!
//! Renew with substantial headroom before local expiry. A cadence around one
//! third of a lease leaves more room than polling at half a lease for
//! scheduling delay, transaction retries, and commit latency. Tune it to the
//! application's latency budget. This is availability guidance, not a safety
//! condition or a durable expiry.
//!
//! A successful acquisition, renewal, reacquisition, or takeover returns a
//! fresh [`Rank`](crate::recipes::ranked_register::Rank). Each is a new fencing
//! epoch, including renewal by the same participant. Once the newer rank is
//! installed, protected work using an older rank, even from that same process,
//! can be rejected. Leadership status alone never authorizes an unfenced
//! external side effect.
//!
//! Ranks returned by this recipe are opaque durable revisions. Do not mix them
//! with [`Rank::new`](crate::recipes::ranked_register::Rank::new) values in the
//! same ranked register or rank space. A manually constructed rank can exceed
//! every future election revision and permanently fence election-backed work.
//!
//! Correctness-sensitive FoundationDB work must use the rank with a
//! [`RankedRegister`](crate::recipes::ranked_register::RankedRegister) in the
//! same enclosing transaction. An external sink must atomically enforce the
//! rank and reject older ranks. See the
//! [`RankedRegister` composition example](crate::recipes::ranked_register#composing-with-leader-election)
//! rather than treating a successful poll as sufficient authorization.
//!
//! ## Safety versus liveness
//!
//! Local expiry does not revoke durable ownership. It only prevents this caller
//! from renewing with its local token and can lead a follower with an unchanged
//! observation to attempt takeover. A failed or unavailable poll cannot renew
//! leadership, so callers must stop protected work that depends on a stale
//! local token. Fencing ranks, not timing alone, protect against a delayed or
//! partitioned process.
//!
//! Do not rely on a background heartbeat that cannot interrupt, fence, or stop
//! in-progress protected work. It can renew a lease, but the protected-work
//! path must still stop when it cannot obtain and use a current fencing rank.
//!
//! ## Protocol walkthrough
//!
//! 1. Create a non-zero-duration
//!    [`LeaderElection`](crate::recipes::leader_election::LeaderElection) and a
//!    fresh [`ParticipantId`](crate::recipes::leader_election::ParticipantId)
//!    for this process incarnation. Start with
//!    [`LocalState::Unknown`](crate::recipes::leader_election::LocalState::Unknown).
//! 2. Poll in a `Database::run` attempt. A released or never-created state
//!    produces [`PollOutcome::Leader`](crate::recipes::leader_election::PollOutcome::Leader)
//!    with [`PollTransition::Acquired`](crate::recipes::leader_election::PollTransition::Acquired).
//!    After the outer transaction succeeds, adopt its
//!    [`Leadership`](crate::recipes::leader_election::Leadership) through
//!    [`PollResult::into_next_state`](crate::recipes::leader_election::PollResult::into_next_state).
//! 3. A caller that sees another owner receives
//!    [`PollOutcome::Follower`](crate::recipes::leader_election::PollOutcome::Follower).
//!    Its next [`LocalState`](crate::recipes::leader_election::LocalState)
//!    contains an [`Observation`](crate::recipes::leader_election::Observation)
//!    of that exact owner, revision, duration, and local observation time.
//!    Repeated polls preserve that time only while the durable record is
//!    unchanged.
//! 4. The current holder polls with its matching, locally unexpired
//!    [`Leadership`](crate::recipes::leader_election::Leadership) and receives
//!    [`PollTransition::Renewed`](crate::recipes::leader_election::PollTransition::Renewed)
//!    with a new rank. An unchanged observation that has waited at least its
//!    persisted duration permits
//!    [`PollTransition::TookOver`](crate::recipes::leader_election::PollTransition::TookOver),
//!    or [`PollTransition::Reacquired`](crate::recipes::leader_election::PollTransition::Reacquired)
//!    when the observer is the same participant.
//! 5. For every leader outcome, co-commit the protected FoundationDB work with
//!    the returned [`Rank`](crate::recipes::ranked_register::Rank), including
//!    [`RankedRegister::read`](crate::recipes::ranked_register::RankedRegister::read)
//!    to install its fence. A later rank fences delayed work using every older
//!    rank.
//! 6. A holder may call
//!    [`LeaderElection::resign`](crate::recipes::leader_election::LeaderElection::resign)
//!    with its exact leadership token. The conditional release preserves the
//!    revision, so the next acquisition receives a strictly newer rank. A stale
//!    resignation is rejected.
//! 7. If the outer run retries, fails, is cancelled, or has an unknown commit,
//!    do not adopt its `PollResult`. The next successful run rediscovers the
//!    durable state. After restart, discard all local state, generate a fresh
//!    participant ID, and follow the observation path again.
//!
//! ## Caller responsibilities
//!
//! The caller owns scheduling, retry policy, transaction options, sleeping,
//! randomization, background work, and caller-local state. This component does
//! not call `Database::run`, retry, set transaction options, sleep, draw random
//! values, start background work, or read wall-clock time.
//!
//! ## Further reading
//!
//! - [AWS Builders Library: Leader Election in Distributed Systems](https://aws.amazon.com/builders-library/leader-election-in-distributed-systems/)
//! - [Martin Kleppmann: How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)
//! - Mike Burrows, "The Chubby Lock Service for Loosely-Coupled Distributed
//!   Systems" (OSDI 2006).
//! - Gregory Chockler and Dahlia Malkhi, "Active Disk Paxos with Infinitely
//!   Many Processes" (PODC 2002).
//! - Salman Niazi, Mahmoud Ismail, Gautier Berthou, and Jim Dowling, "Leader
//!   Election Using NewSQL Database Systems" (DAIS 2015, LNCS 9038).
//! - [AWS Labs: Amazon DynamoDB Lock Client](https://github.com/awslabs/amazon-dynamodb-lock-client)
//! - [Apache ZooKeeper Recipes, GUID note](https://zookeeper.apache.org/doc/r3.5.5/recipes.html)

mod algorithm;
mod errors;
mod keys;
mod types;

pub use errors::{LeaderElectionError, Result};
pub use types::{
    ElectionState, Leadership, LocalState, Observation, ParticipantId, PollOutcome, PollResult,
    PollTransition, ResignOutcome,
};

use crate::{Transaction, tuple::Subspace};
use std::ops::Deref;
use std::time::Duration;

/// A handle for one independently scoped leader lease.
///
/// `lease_duration` is this caller's non-zero desired duration. It is
/// persisted on every successful acquisition, takeover, and renewal. Existing
/// foreign records remain governed by their own persisted duration. The handle
/// holds no caller-local state and performs no scheduling or retries.
#[derive(Clone, Debug)]
pub struct LeaderElection {
    subspace: Subspace,
    lease_duration: Duration,
}

impl LeaderElection {
    /// Creates an election handle with the non-zero duration it will persist on
    /// successful ownership changes.
    ///
    /// Constructing a handle does not read or initialize durable state.
    #[cfg_attr(
        feature = "trace",
        tracing::instrument(level = "debug", skip(subspace))
    )]
    pub fn new(subspace: Subspace, lease_duration: Duration) -> Result<Self> {
        if lease_duration.is_zero() {
            return Err(LeaderElectionError::InvalidLeaseDuration);
        }
        Ok(Self {
            subspace,
            lease_duration,
        })
    }

    /// Returns this handle's desired duration for its future owner records.
    #[cfg_attr(feature = "trace", tracing::instrument(level = "debug", skip(self)))]
    pub fn lease_duration(&self) -> Duration {
        self.lease_duration
    }

    /// Polls the durable lease state in the caller's transaction.
    ///
    /// A released state is acquired immediately. An exact, locally unexpired
    /// [`Leadership`] token renews ownership with a fresh revision. A first or
    /// changed [`Observation`] never steals; only an exact unchanged observation
    /// may take over, or same-owner reacquire, after the observed record's
    /// persisted duration. An expired or mismatched leadership token becomes
    /// observation/reacquisition state and cannot renew directly.
    ///
    /// `attempt_started_at` must be read from the caller's monotonic clock
    /// immediately before this call in each retry attempt. It is deliberately
    /// before the durable read, making renewal and takeover decisions
    /// conservative relative to read and commit delay. After the enclosing
    /// `Database::run` succeeds, pass a fresh caller-clock reading to
    /// [`PollResult::into_next_state`] to adopt the returned state.
    ///
    /// Renew well before the local deadline represented by [`Leadership`],
    /// leaving headroom for scheduling delay, retries, and commit latency.
    /// This affects availability only; it is not a durable expiry or safety
    /// condition.
    #[cfg_attr(
        feature = "trace",
        tracing::instrument(
            level = "debug",
            skip(self, txn, participant, local_state),
            fields(participant = participant.as_str())
        )
    )]
    pub async fn poll<T>(
        &self,
        txn: &T,
        participant: &ParticipantId,
        local_state: &LocalState,
        attempt_started_at: Duration,
    ) -> Result<PollResult>
    where
        T: Deref<Target = Transaction>,
    {
        algorithm::poll(
            txn,
            &self.subspace,
            self.lease_duration,
            participant,
            local_state,
            attempt_started_at,
        )
        .await
    }

    /// Reads durable state without making any liveness or leadership-validity claim.
    ///
    /// This is a snapshot only. It does not create a local observation, permit
    /// renewal or takeover, or authorize protected work.
    #[cfg_attr(
        feature = "trace",
        tracing::instrument(level = "debug", skip(self, txn))
    )]
    pub async fn state<T>(&self, txn: &T) -> Result<ElectionState>
    where
        T: Deref<Target = Transaction>,
    {
        algorithm::state(txn, &self.subspace).await
    }

    /// Releases ownership only when `leadership` still exactly matches durable state.
    ///
    /// The revision and persisted duration remain, so a later acquisition has
    /// a strictly newer fencing rank. A stale delayed resignation is rejected.
    /// This conditional operation does not make a liveness claim and may be
    /// used to relinquish an otherwise exact durable token after its local
    /// renewal window has elapsed.
    #[cfg_attr(
        feature = "trace",
        tracing::instrument(
            level = "debug",
            skip(self, txn, leadership),
            fields(
                participant = leadership.participant().as_str(),
                leadership_revision = leadership.rank().as_u64()
            )
        )
    )]
    pub async fn resign<T>(&self, txn: &T, leadership: &Leadership) -> Result<ResignOutcome>
    where
        T: Deref<Target = Transaction>,
    {
        algorithm::resign(txn, &self.subspace, leadership).await
    }
}