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
5162type 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+
5383impl 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 ) ]
152217pub 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) ]
170235mod 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