Skip to main content

corium_transactor/
lease.rs

1//! Write leases carried on the database root record
2//! (see `docs/design/log-and-transactor.md`).
3//!
4//! Since format 2, the lease *is* part of the [`DbRoot`] record:
5//! `{owner, version, expiry, endpoint}` live next to the index roots, and
6//! every mutation — acquisition, renewal, release, index publication — is a
7//! CAS on that one record. Acquiring the lease with a new version therefore
8//! rewrites the record a deposed transactor would have to CAS against, so a
9//! writer that lost ownership always fails its next root CAS: the fencing
10//! check and the publication are the same atomic operation, and no separate
11//! fence-bump step exists.
12//!
13//! Pre-M7 deployments kept the lease in a separate `lease:{db}` record;
14//! [`acquire`] folds any such record into the root once (respecting its
15//! version so fencing never regresses) and deletes it.
16
17use corium_store::{DbRoot, FORMAT_VERSION, RootStore, StoreError, db_root_name};
18use thiserror::Error;
19
20/// Root-store key of the pre-format-2 standalone lease record.
21#[must_use]
22pub fn lease_root(db: &str) -> String {
23    format!("lease:{db}")
24}
25
26/// A held write lease (the lease fields of the database root).
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct Lease {
29    /// Owning transactor id.
30    pub owner: String,
31    /// Fencing version; increments on every change of ownership.
32    pub version: u64,
33    /// Expiry as Unix milliseconds.
34    pub expires_unix_ms: i64,
35    /// Client endpoint advertised to peers; empty when unadvertised.
36    pub endpoint: String,
37}
38
39/// Lease acquisition/renewal failure.
40#[derive(Debug, Error)]
41pub enum LeaseError {
42    /// Another owner holds an unexpired lease.
43    #[error("lease held by {owner} until {expires_unix_ms}")]
44    Held {
45        /// Current owner id.
46        owner: String,
47        /// Expiry of the conflicting lease.
48        expires_unix_ms: i64,
49    },
50    /// This owner no longer holds the lease.
51    #[error("lease lost to another owner")]
52    Lost,
53    /// Root store failure.
54    #[error(transparent)]
55    Store(#[from] StoreError),
56}
57
58/// Pre-format-2 standalone lease record: `owner\nversion\nexpiry\n`.
59fn decode_legacy(bytes: &[u8]) -> Option<(String, u64, i64)> {
60    let text = std::str::from_utf8(bytes).ok()?;
61    let mut lines = text.lines();
62    Some((
63        lines.next()?.to_owned(),
64        lines.next()?.parse().ok()?,
65        lines.next()?.parse().ok()?,
66    ))
67}
68
69/// The lease view of a stored root: owner, version, expiry.
70fn holder(root: Option<&DbRoot>, legacy: Option<&(String, u64, i64)>) -> (String, u64, i64) {
71    match root {
72        Some(root) if !root.owner.is_empty() => (
73            root.owner.clone(),
74            root.lease_version,
75            root.lease_expires_unix_ms,
76        ),
77        Some(root) => match legacy {
78            Some((owner, version, expiry)) => {
79                (owner.clone(), (*version).max(root.lease_version), *expiry)
80            }
81            None => (String::new(), root.lease_version, 0),
82        },
83        None => match legacy {
84            Some((owner, version, expiry)) => (owner.clone(), *version, *expiry),
85            None => (String::new(), 0, 0),
86        },
87    }
88}
89
90/// Acquires (or re-acquires) the write lease for `db` with a CAS on the
91/// lease fields of the database root. Acquisition under a new version is
92/// itself the fence: any deposed writer's pending root CAS now has stale
93/// expected bytes and fails.
94///
95/// The version increments only when ownership changes hands; a same-owner
96/// acquisition keeps its version.
97///
98/// # Errors
99/// Returns [`LeaseError::Held`] while another owner's lease is unexpired.
100pub async fn acquire(
101    store: &dyn RootStore,
102    db: &str,
103    owner: &str,
104    endpoint: &str,
105    ttl_ms: i64,
106    now_unix_ms: i64,
107) -> Result<Lease, LeaseError> {
108    let name = db_root_name(db);
109    let legacy_name = lease_root(db);
110    loop {
111        let current_bytes = store.get_root(&name).await?;
112        let current = current_bytes.as_deref().and_then(DbRoot::decode);
113        let legacy = store
114            .get_root(&legacy_name)
115            .await?
116            .as_deref()
117            .and_then(decode_legacy);
118        let (cur_owner, cur_version, cur_expiry) = holder(current.as_ref(), legacy.as_ref());
119        let version = if !cur_owner.is_empty() && cur_owner == owner {
120            cur_version
121        } else if cur_expiry > now_unix_ms {
122            return Err(LeaseError::Held {
123                owner: cur_owner,
124                expires_unix_ms: cur_expiry,
125            });
126        } else {
127            cur_version + 1
128        };
129        let next = DbRoot {
130            format_version: FORMAT_VERSION,
131            lease_version: version,
132            owner: owner.to_owned(),
133            lease_expires_unix_ms: now_unix_ms + ttl_ms,
134            owner_endpoint: endpoint.to_owned(),
135            index_basis_t: current.as_ref().map_or(0, |root| root.index_basis_t),
136            roots: current.as_ref().and_then(|root| root.roots.clone()),
137            // Preserve the published snapshot's recovery hints across the
138            // fence bump so a successor can still recover from the index root.
139            next_entity_id: current.as_ref().map_or(0, |root| root.next_entity_id),
140            last_tx_instant: current
141                .as_ref()
142                .map_or(i64::MIN, |root| root.last_tx_instant),
143        };
144        match store
145            .cas_root(&name, current_bytes.as_deref(), &next.encode())
146            .await
147        {
148            Ok(()) => {
149                if legacy.is_some() {
150                    // Folded into the root; fencing now lives there alone.
151                    let _ = store.delete_root(&legacy_name).await;
152                }
153                return Ok(Lease {
154                    owner: owner.to_owned(),
155                    version,
156                    expires_unix_ms: next.lease_expires_unix_ms,
157                    endpoint: endpoint.to_owned(),
158                });
159            }
160            Err(StoreError::CasFailed { .. }) => {}
161            Err(error) => return Err(error.into()),
162        }
163    }
164}
165
166/// Renews a held lease; fails with [`LeaseError::Lost`] when ownership has
167/// changed hands. Index fields published concurrently under the same lease
168/// are preserved (the CAS retries around them).
169///
170/// # Errors
171/// Returns [`LeaseError::Lost`] on any ownership change.
172pub async fn renew(
173    store: &dyn RootStore,
174    db: &str,
175    held: &Lease,
176    ttl_ms: i64,
177    now_unix_ms: i64,
178) -> Result<Lease, LeaseError> {
179    let name = db_root_name(db);
180    loop {
181        let current_bytes = store.get_root(&name).await?;
182        let Some(root) = current_bytes.as_deref().and_then(DbRoot::decode) else {
183            return Err(LeaseError::Lost);
184        };
185        if root.owner != held.owner || root.lease_version != held.version {
186            return Err(LeaseError::Lost);
187        }
188        let next = DbRoot {
189            lease_expires_unix_ms: now_unix_ms + ttl_ms,
190            owner_endpoint: held.endpoint.clone(),
191            ..root
192        };
193        match store
194            .cas_root(&name, current_bytes.as_deref(), &next.encode())
195            .await
196        {
197            Ok(()) => {
198                return Ok(Lease {
199                    expires_unix_ms: next.lease_expires_unix_ms,
200                    ..held.clone()
201                });
202            }
203            // A concurrent index publication under our own lease raced the
204            // CAS; re-read and retry. Genuine loss is caught by the
205            // ownership check above.
206            Err(StoreError::CasFailed { .. }) => {}
207            Err(error) => return Err(error.into()),
208        }
209    }
210}
211
212/// Verifies this writer still owns the lease. Used before *and after* the
213/// durable log append: the post-append check guarantees no transaction is
214/// acknowledged unless ownership was intact after its record became
215/// durable, which is what makes takeover replay complete (see
216/// `docs/design/log-and-transactor.md`).
217///
218/// # Errors
219/// Returns [`LeaseError::Lost`] when ownership has changed hands.
220pub async fn verify(store: &dyn RootStore, db: &str, held: &Lease) -> Result<(), LeaseError> {
221    let root = store
222        .get_root(&db_root_name(db))
223        .await?
224        .as_deref()
225        .and_then(DbRoot::decode);
226    match root {
227        Some(root) if root.owner == held.owner && root.lease_version == held.version => Ok(()),
228        _ => Err(LeaseError::Lost),
229    }
230}
231
232/// Releases a held lease by expiring it immediately. Ownership changes
233/// after release still increment the version (the record remains).
234///
235/// # Errors
236/// Returns [`LeaseError::Lost`] when the lease is no longer held.
237pub async fn release(store: &dyn RootStore, db: &str, held: &Lease) -> Result<(), LeaseError> {
238    let name = db_root_name(db);
239    loop {
240        let current_bytes = store.get_root(&name).await?;
241        let Some(root) = current_bytes.as_deref().and_then(DbRoot::decode) else {
242            return Err(LeaseError::Lost);
243        };
244        if root.owner != held.owner || root.lease_version != held.version {
245            return Err(LeaseError::Lost);
246        }
247        let next = DbRoot {
248            lease_expires_unix_ms: 0,
249            ..root
250        };
251        match store
252            .cas_root(&name, current_bytes.as_deref(), &next.encode())
253            .await
254        {
255            Ok(()) => return Ok(()),
256            Err(StoreError::CasFailed { .. }) => {}
257            Err(error) => return Err(error.into()),
258        }
259    }
260}