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 ) ]
2020pub 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
4960type 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+
5181impl 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 ) ]
145210pub 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) ]
163228mod 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