Skip to content

Commit 1257b63

Browse files
committed
Renew leases, detect theft, etc
1 parent 087db2c commit 1257b63

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
@@ -19,9 +19,11 @@ pub static LEASE_FILENAME: &str = "LEASE";
1919
#[derive(Debug)]
2020
pub struct Lease {
2121
transport: Transport,
22-
lease_taken: OffsetDateTime,
22+
content: LeaseContent,
2323
/// The next refresh after this time must rewrite the lease.
2424
next_renewal: OffsetDateTime,
25+
/// How often should we renew the lease?
26+
renewal_interval: Duration,
2527
}
2628

2729
#[non_exhaustive]
@@ -44,18 +46,47 @@ pub enum Error {
4446

4547
#[error("JSON serialization error in lease {url}: {source}")]
4648
Json { source: serde_json::Error, url: Url },
49+
50+
#[error("Lease {url} was stolen: {content:?}")]
51+
Stolen {
52+
url: Url,
53+
content: Box<LeaseContent>,
54+
},
55+
56+
#[error("Lease {url} disappeared")]
57+
Disappeared { url: Url },
4758
}
4859

4960
type Result<T> = std::result::Result<T, Error>;
5061

62+
/// Options controlling lease behavior, exposed for testing.
63+
#[derive(Clone, Debug, Eq, PartialEq)]
64+
pub struct LeaseOptions {
65+
/// How long do leases last before they're assumed stale?
66+
lease_expiry: Duration,
67+
68+
/// Renew the lease soon after it becomes this old.
69+
renewal_interval: Duration,
70+
}
71+
72+
impl Default for LeaseOptions {
73+
fn default() -> Self {
74+
Self {
75+
lease_expiry: Duration::from_secs(60),
76+
renewal_interval: Duration::from_secs(10),
77+
}
78+
}
79+
}
80+
5181
impl Lease {
5282
/// Acquire a lease, if one is available.
5383
///
5484
/// Returns [Error::Busy] or [Error::Corrupt] if the lease is already held by another process.
5585
#[instrument]
56-
pub fn acquire(transport: &Transport) -> Result<Self> {
86+
pub fn acquire(transport: &Transport, lease_options: &LeaseOptions) -> Result<Self> {
87+
trace!("trying to acquire lease");
5788
let lease_taken = OffsetDateTime::now_utc();
58-
let lease_expiry = lease_taken + Duration::from_secs(5 * 60);
89+
let lease_expiry = lease_taken + lease_options.lease_expiry;
5990
let content = LeaseContent {
6091
host: hostname::get()
6192
.unwrap_or_default()
@@ -64,8 +95,8 @@ impl Lease {
6495
.into(),
6596
pid: Some(process::id()),
6697
client_version: Some(crate::VERSION.to_string()),
67-
lease_taken,
68-
lease_expiry,
98+
acquired: lease_taken,
99+
expiry: lease_expiry,
69100
};
70101
let url = transport.relative_file_url(LEASE_FILENAME);
71102
let mut s: String = serde_json::to_string(&content).expect("serialize lease");
@@ -94,11 +125,45 @@ impl Lease {
94125
let next_renewal = lease_taken + Duration::from_secs(60);
95126
Ok(Lease {
96127
transport: transport.clone(),
97-
lease_taken,
128+
content,
98129
next_renewal,
130+
renewal_interval: lease_options.renewal_interval,
99131
})
100132
}
101133

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

143208
/// Contents of the lease file.
144-
#[derive(Debug, Serialize, Deserialize, Clone)]
209+
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
145210
pub struct LeaseContent {
146211
/// Hostname of the client process
147212
pub host: Option<String>,
@@ -152,29 +217,35 @@ pub struct LeaseContent {
152217

153218
/// Time when the lease was taken.
154219
#[serde(with = "time::serde::iso8601")]
155-
pub lease_taken: OffsetDateTime,
220+
pub acquired: OffsetDateTime,
156221

157222
/// Unix time after which this lease is stale.
158223
#[serde(with = "time::serde::iso8601")]
159-
pub lease_expiry: OffsetDateTime,
224+
pub expiry: OffsetDateTime,
160225
}
161226

162227
#[cfg(test)]
163228
mod test {
164229
use std::fs::{write, File};
165230
use std::process;
231+
use std::time::Duration;
166232

233+
use assert_matches::assert_matches;
167234
use tempfile::TempDir;
168235

169236
use super::*;
170237

171238
#[test]
172239
fn take_lease() {
240+
let options = super::LeaseOptions {
241+
lease_expiry: Duration::from_secs(60),
242+
renewal_interval: Duration::from_secs(10),
243+
};
173244
let tmp = TempDir::new().unwrap();
174245
let transport = &Transport::local(tmp.path());
175-
let lease = Lease::acquire(transport).unwrap();
246+
let lease = Lease::acquire(transport, &options).unwrap();
176247
assert!(tmp.path().join("LEASE").exists());
177-
assert!(lease.next_renewal > lease.lease_taken);
248+
let orig_lease_taken = lease.content.acquired;
178249

179250
let peeked = Lease::peek(transport).unwrap();
180251
let LeaseState::Held(content) = peeked else {
@@ -186,10 +257,60 @@ mod test {
186257
);
187258
assert_eq!(content.pid, Some(process::id()));
188259

260+
let lease = lease.renew().unwrap();
261+
let state2 = Lease::peek(transport).unwrap();
262+
match state2 {
263+
LeaseState::Held(content) => {
264+
assert!(content.acquired > orig_lease_taken);
265+
}
266+
_ => panic!("lease should be held, got {state2:?}"),
267+
}
268+
189269
lease.release().unwrap();
190270
assert!(!tmp.path().join("LEASE").exists());
191271
}
192272

273+
#[test]
274+
fn fail_to_renew_deleted_lease() {
275+
let options = super::LeaseOptions {
276+
lease_expiry: Duration::from_secs(60),
277+
renewal_interval: Duration::from_secs(10),
278+
};
279+
let tmp = TempDir::new().unwrap();
280+
let transport = Transport::local(tmp.path());
281+
let lease = Lease::acquire(&transport, &options).unwrap();
282+
assert!(tmp.path().join("LEASE").exists());
283+
284+
transport.remove_file(LEASE_FILENAME).unwrap();
285+
286+
let result = lease.renew();
287+
assert_matches!(result, Err(super::Error::Disappeared { .. }));
288+
}
289+
290+
#[test]
291+
fn fail_to_renew_stolen_lease() {
292+
let options = super::LeaseOptions {
293+
lease_expiry: Duration::from_secs(60),
294+
renewal_interval: Duration::from_secs(10),
295+
};
296+
let tmp = TempDir::new().unwrap();
297+
let transport = Transport::local(tmp.path());
298+
let lease1 = Lease::acquire(&transport, &options).unwrap();
299+
assert!(tmp.path().join("LEASE").exists());
300+
301+
// Delete the lease to make it easy to steal.
302+
transport.remove_file(LEASE_FILENAME).unwrap();
303+
let lease2 = Lease::acquire(&transport, &options).unwrap();
304+
assert!(tmp.path().join("LEASE").exists());
305+
306+
// Renewal through the first handle should now fail.
307+
let result = lease1.renew();
308+
assert_matches!(result, Err(super::Error::Stolen { .. }));
309+
310+
// Lease 2 can still renew.
311+
lease2.renew().unwrap();
312+
}
313+
193314
#[test]
194315
fn peek_fixed_lease_content() {
195316
let tmp = TempDir::new().unwrap();
@@ -201,8 +322,8 @@ mod test {
201322
"host": "somehost",
202323
"pid": 1234,
203324
"client_version": "0.1.2",
204-
"lease_taken": "2021-01-01T12:34:56Z",
205-
"lease_expiry": "2021-01-01T12:35:56Z"
325+
"acquired": "2021-01-01T12:34:56Z",
326+
"expiry": "2021-01-01T12:35:56Z"
206327
}"#,
207328
)
208329
.unwrap();
@@ -213,10 +334,10 @@ mod test {
213334
assert_eq!(content.host.unwrap(), "somehost");
214335
assert_eq!(content.pid, Some(1234));
215336
assert_eq!(content.client_version.unwrap(), "0.1.2");
216-
assert_eq!(content.lease_taken.year(), 2021);
217-
assert_eq!(content.lease_expiry.year(), 2021);
337+
assert_eq!(content.acquired.year(), 2021);
338+
assert_eq!(content.expiry.year(), 2021);
218339
assert_eq!(
219-
content.lease_expiry - content.lease_taken,
340+
content.expiry - content.acquired,
220341
time::Duration::seconds(60)
221342
);
222343
}

0 commit comments

Comments
 (0)