Skip to content

Commit f3eaaff

Browse files
committed
Renew leases, detect theft, etc
1 parent 257fe0b commit f3eaaff

1 file changed

Lines changed: 138 additions & 17 deletions

File tree

src/lease.rs

Lines changed: 138 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2024 Martin Pool
1+
// Copyright 2024-2025 Martin Pool
22

33
//! Leases controlling write access to an archive.
44
@@ -21,9 +21,11 @@ pub struct Lease {
2121
transport: Transport,
2222
/// URL of the lease file.
2323
url: Url,
24-
lease_taken: OffsetDateTime,
24+
content: LeaseContent,
2525
/// The next refresh after this time must rewrite the lease.
2626
next_renewal: OffsetDateTime,
27+
/// How often should we renew the lease?
28+
renewal_interval: Duration,
2729
}
2830

2931
#[non_exhaustive]
@@ -46,18 +48,47 @@ pub enum Error {
4648

4749
#[error("JSON serialization error in lease {url}: {source}")]
4850
Json { source: serde_json::Error, url: Url },
51+
52+
#[error("Lease {url} was stolen: {content:?}")]
53+
Stolen {
54+
url: Url,
55+
content: Box<LeaseContent>,
56+
},
57+
58+
#[error("Lease {url} disappeared")]
59+
Disappeared { url: Url },
4960
}
5061

5162
type Result<T> = std::result::Result<T, Error>;
5263

64+
/// Options controlling lease behavior, exposed for testing.
65+
#[derive(Clone, Debug, Eq, PartialEq)]
66+
pub struct LeaseOptions {
67+
/// How long do leases last before they're assumed stale?
68+
lease_expiry: Duration,
69+
70+
/// Renew the lease soon after it becomes this old.
71+
renewal_interval: Duration,
72+
}
73+
74+
impl Default for LeaseOptions {
75+
fn default() -> Self {
76+
Self {
77+
lease_expiry: Duration::from_secs(60),
78+
renewal_interval: Duration::from_secs(10),
79+
}
80+
}
81+
}
82+
5383
impl Lease {
5484
/// Acquire a lease, if one is available.
5585
///
5686
/// Returns [Error::Busy] or [Error::Corrupt] if the lease is already held by another process.
5787
#[instrument]
58-
pub async fn acquire(transport: &Transport) -> Result<Self> {
88+
pub async fn acquire(transport: &Transport, lease_options: &LeaseOptions) -> Result<Self> {
89+
trace!("trying to acquire lease");
5990
let lease_taken = OffsetDateTime::now_utc();
60-
let lease_expiry = lease_taken + Duration::from_secs(5 * 60);
91+
let lease_expiry = lease_taken + lease_options.lease_expiry;
6192
let content = LeaseContent {
6293
host: hostname::get()
6394
.unwrap_or_default()
@@ -66,8 +97,8 @@ impl Lease {
6697
.into(),
6798
pid: Some(process::id()),
6899
client_version: Some(crate::VERSION.to_string()),
69-
lease_taken,
70-
lease_expiry,
100+
acquired: lease_taken,
101+
expiry: lease_expiry,
71102
};
72103
let url = transport.url().join(LEASE_FILENAME).unwrap();
73104
let mut s: String = serde_json::to_string(&content).expect("serialize lease");
@@ -100,11 +131,45 @@ impl Lease {
100131
Ok(Lease {
101132
transport: transport.clone(),
102133
url,
103-
lease_taken,
134+
content,
104135
next_renewal,
136+
renewal_interval: lease_options.renewal_interval,
105137
})
106138
}
107139

140+
/// Unconditionally renew a held lease, after checking that it was not stolen.
141+
///
142+
/// This takes the existing lease and returns a new one only if renewal succeeds.
143+
pub async fn renew(mut self) -> Result<Self> {
144+
let state = Lease::peek(&self.transport).await?;
145+
match state {
146+
LeaseState::Held(content) => {
147+
if content != self.content {
148+
warn!(actual = ?content, expected = ?self.content, "lease stolen");
149+
return Err(Error::Stolen {
150+
url: self.url,
151+
content: Box::new(content),
152+
});
153+
}
154+
}
155+
LeaseState::Free => {
156+
warn!("lease file disappeared");
157+
return Err(Error::Disappeared { url: self.url });
158+
}
159+
LeaseState::Corrupt(_mtime) => {
160+
warn!("lease file is corrupt");
161+
return Err(Error::Corrupt { url: self.url });
162+
}
163+
}
164+
self.content.acquired = OffsetDateTime::now_utc();
165+
self.next_renewal = self.content.acquired + self.renewal_interval;
166+
let json: String = serde_json::to_string(&self.content).expect("serialize lease");
167+
self.transport
168+
.write(LEASE_FILENAME, json.as_bytes(), WriteMode::Overwrite)
169+
.await?;
170+
Ok(self)
171+
}
172+
108173
#[instrument]
109174
pub async fn release(self) -> Result<()> {
110175
// TODO: Check that it was not stolen?
@@ -148,7 +213,7 @@ pub enum LeaseState {
148213
}
149214

150215
/// Contents of the lease file.
151-
#[derive(Debug, Serialize, Deserialize, Clone)]
216+
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
152217
pub struct LeaseContent {
153218
/// Hostname of the client process
154219
pub host: Option<String>,
@@ -159,29 +224,35 @@ pub struct LeaseContent {
159224

160225
/// Time when the lease was taken.
161226
#[serde(with = "time::serde::iso8601")]
162-
pub lease_taken: OffsetDateTime,
227+
pub acquired: OffsetDateTime,
163228

164229
/// Unix time after which this lease is stale.
165230
#[serde(with = "time::serde::iso8601")]
166-
pub lease_expiry: OffsetDateTime,
231+
pub expiry: OffsetDateTime,
167232
}
168233

169234
#[cfg(test)]
170235
mod test {
171236
use std::fs::{write, File};
172237
use std::process;
238+
use std::time::Duration;
173239

240+
use assert_matches::assert_matches;
174241
use tempfile::TempDir;
175242

176243
use super::*;
177244

178245
#[tokio::test]
179246
async fn take_lease() {
247+
let options = super::LeaseOptions {
248+
lease_expiry: Duration::from_secs(60),
249+
renewal_interval: Duration::from_secs(10),
250+
};
180251
let tmp = TempDir::new().unwrap();
181252
let transport = &Transport::local(tmp.path());
182-
let lease = Lease::acquire(transport).await.unwrap();
253+
let lease = Lease::acquire(transport, &options).await.unwrap();
183254
assert!(tmp.path().join("LEASE").exists());
184-
assert!(lease.next_renewal > lease.lease_taken);
255+
let orig_lease_taken = lease.content.acquired;
185256

186257
let peeked = Lease::peek(transport).await.unwrap();
187258
let LeaseState::Held(content) = peeked else {
@@ -193,10 +264,60 @@ mod test {
193264
);
194265
assert_eq!(content.pid, Some(process::id()));
195266

267+
let lease = lease.renew().await.unwrap();
268+
let state2 = Lease::peek(transport).await.unwrap();
269+
match state2 {
270+
LeaseState::Held(content) => {
271+
assert!(content.acquired > orig_lease_taken);
272+
}
273+
_ => panic!("lease should be held, got {state2:?}"),
274+
}
275+
196276
lease.release().await.unwrap();
197277
assert!(!tmp.path().join("LEASE").exists());
198278
}
199279

280+
#[tokio::test]
281+
async fn fail_to_renew_deleted_lease() {
282+
let options = super::LeaseOptions {
283+
lease_expiry: Duration::from_secs(60),
284+
renewal_interval: Duration::from_secs(10),
285+
};
286+
let tmp = TempDir::new().unwrap();
287+
let transport = Transport::local(tmp.path());
288+
let lease = Lease::acquire(&transport, &options).await.unwrap();
289+
assert!(tmp.path().join("LEASE").exists());
290+
291+
transport.remove_file(LEASE_FILENAME).await.unwrap();
292+
293+
let result = lease.renew().await;
294+
assert_matches!(result, Err(super::Error::Disappeared { .. }));
295+
}
296+
297+
#[tokio::test]
298+
async fn fail_to_renew_stolen_lease() {
299+
let options = super::LeaseOptions {
300+
lease_expiry: Duration::from_secs(60),
301+
renewal_interval: Duration::from_secs(10),
302+
};
303+
let tmp = TempDir::new().unwrap();
304+
let transport = Transport::local(tmp.path());
305+
let lease1 = Lease::acquire(&transport, &options).await.unwrap();
306+
assert!(tmp.path().join("LEASE").exists());
307+
308+
// Delete the lease to make it easy to steal.
309+
transport.remove_file(LEASE_FILENAME).await.unwrap();
310+
let lease2 = Lease::acquire(&transport, &options).await.unwrap();
311+
assert!(tmp.path().join("LEASE").exists());
312+
313+
// Renewal through the first handle should now fail.
314+
let result = lease1.renew().await;
315+
assert_matches!(result, Err(super::Error::Stolen { .. }));
316+
317+
// Lease 2 can still renew.
318+
lease2.renew().await.unwrap();
319+
}
320+
200321
#[tokio::test]
201322
async fn peek_fixed_lease_content() {
202323
let tmp = TempDir::new().unwrap();
@@ -208,8 +329,8 @@ mod test {
208329
"host": "somehost",
209330
"pid": 1234,
210331
"client_version": "0.1.2",
211-
"lease_taken": "2021-01-01T12:34:56Z",
212-
"lease_expiry": "2021-01-01T12:35:56Z"
332+
"acquired": "2021-01-01T12:34:56Z",
333+
"expiry": "2021-01-01T12:35:56Z"
213334
}"#,
214335
)
215336
.unwrap();
@@ -220,10 +341,10 @@ mod test {
220341
assert_eq!(content.host.unwrap(), "somehost");
221342
assert_eq!(content.pid, Some(1234));
222343
assert_eq!(content.client_version.unwrap(), "0.1.2");
223-
assert_eq!(content.lease_taken.year(), 2021);
224-
assert_eq!(content.lease_expiry.year(), 2021);
344+
assert_eq!(content.acquired.year(), 2021);
345+
assert_eq!(content.expiry.year(), 2021);
225346
assert_eq!(
226-
content.lease_expiry - content.lease_taken,
347+
content.expiry - content.acquired,
227348
time::Duration::seconds(60)
228349
);
229350
}

0 commit comments

Comments
 (0)