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            // The key manifest belongs to `corium keys`, not to the fence:
144            // a lease bump must carry it forward untouched.
145            key_manifest_version: current.as_ref().map_or(0, |root| root.key_manifest_version),
146        };
147        match store
148            .cas_root(&name, current_bytes.as_deref(), &next.encode())
149            .await
150        {
151            Ok(()) => {
152                if legacy.is_some() {
153                    // Folded into the root; fencing now lives there alone.
154                    let _ = store.delete_root(&legacy_name).await;
155                }
156                return Ok(Lease {
157                    owner: owner.to_owned(),
158                    version,
159                    expires_unix_ms: next.lease_expires_unix_ms,
160                    endpoint: endpoint.to_owned(),
161                });
162            }
163            Err(StoreError::CasFailed { .. }) => {}
164            Err(error) => return Err(error.into()),
165        }
166    }
167}
168
169/// Renews a held lease; fails with [`LeaseError::Lost`] when ownership has
170/// changed hands. Index fields published concurrently under the same lease
171/// are preserved (the CAS retries around them).
172///
173/// # Errors
174/// Returns [`LeaseError::Lost`] on any ownership change.
175pub async fn renew(
176    store: &dyn RootStore,
177    db: &str,
178    held: &Lease,
179    ttl_ms: i64,
180    now_unix_ms: i64,
181) -> Result<Lease, LeaseError> {
182    let name = db_root_name(db);
183    loop {
184        let current_bytes = store.get_root(&name).await?;
185        let Some(root) = current_bytes.as_deref().and_then(DbRoot::decode) else {
186            return Err(LeaseError::Lost);
187        };
188        if root.owner != held.owner || root.lease_version != held.version {
189            return Err(LeaseError::Lost);
190        }
191        let next = DbRoot {
192            lease_expires_unix_ms: now_unix_ms + ttl_ms,
193            owner_endpoint: held.endpoint.clone(),
194            ..root
195        };
196        match store
197            .cas_root(&name, current_bytes.as_deref(), &next.encode())
198            .await
199        {
200            Ok(()) => {
201                return Ok(Lease {
202                    expires_unix_ms: next.lease_expires_unix_ms,
203                    ..held.clone()
204                });
205            }
206            // A concurrent index publication under our own lease raced the
207            // CAS; re-read and retry. Genuine loss is caught by the
208            // ownership check above.
209            Err(StoreError::CasFailed { .. }) => {}
210            Err(error) => return Err(error.into()),
211        }
212    }
213}
214
215/// Verifies this writer still owns the lease. Used before *and after* the
216/// durable log append: the post-append check guarantees no transaction is
217/// acknowledged unless ownership was intact after its record became
218/// durable, which is what makes takeover replay complete (see
219/// `docs/design/log-and-transactor.md`).
220///
221/// # Errors
222/// Returns [`LeaseError::Lost`] when ownership has changed hands.
223pub async fn verify(store: &dyn RootStore, db: &str, held: &Lease) -> Result<(), LeaseError> {
224    let root = store
225        .get_root(&db_root_name(db))
226        .await?
227        .as_deref()
228        .and_then(DbRoot::decode);
229    match root {
230        Some(root) if root.owner == held.owner && root.lease_version == held.version => Ok(()),
231        _ => Err(LeaseError::Lost),
232    }
233}
234
235/// Releases a held lease by expiring it immediately. Ownership changes
236/// after release still increment the version (the record remains).
237///
238/// # Errors
239/// Returns [`LeaseError::Lost`] when the lease is no longer held.
240pub async fn release(store: &dyn RootStore, db: &str, held: &Lease) -> Result<(), LeaseError> {
241    let name = db_root_name(db);
242    loop {
243        let current_bytes = store.get_root(&name).await?;
244        let Some(root) = current_bytes.as_deref().and_then(DbRoot::decode) else {
245            return Err(LeaseError::Lost);
246        };
247        if root.owner != held.owner || root.lease_version != held.version {
248            return Err(LeaseError::Lost);
249        }
250        let next = DbRoot {
251            lease_expires_unix_ms: 0,
252            ..root
253        };
254        match store
255            .cas_root(&name, current_bytes.as_deref(), &next.encode())
256            .await
257        {
258            Ok(()) => return Ok(()),
259            Err(StoreError::CasFailed { .. }) => {}
260            Err(error) => return Err(error.into()),
261        }
262    }
263}