1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use anyhow::{Result, bail};
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use revision::revisioned;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use tracing::trace;
use uuid::Uuid;
use web_time::Instant;
use crate::err::Error;
use crate::key::root::tl::Tl;
use crate::kvs::ds::TransactionFactory;
use crate::kvs::sequences::Sequences;
use crate::kvs::{
Error as KvsError, LockType, Transaction, TransactionType, impl_kv_value_revisioned,
};
#[derive(Debug, Clone)]
pub(crate) enum TaskLeaseType {
/// Task for cleaning up old changefeed data
ChangeFeedCleanup,
/// Index compaction
IndexCompaction,
/// Resuming index builds left unfinished by a crashed or expired owner node
IndexBuildResume,
/// Event processing
EventProcessing,
/// Background reclaim of tombstoned namespace/database/index data
ReclaimTombstones,
/// Purging expired durable RPC sessions
RpcSessionCleanup,
}
/// Represents a distributed task lease stored in the datastore.
///
/// A TaskLease records which node currently owns the exclusive right to perform
/// a specific task, and when that right expires. The lease is stored in the
/// datastore and checked/updated atomically to ensure only one node can hold
/// the lease at any given time.
///
/// # Fields
/// * `owner` - UUID of the node that currently owns this lease
/// * `expiration` - UTC timestamp when this lease will expire
#[revisioned(revision = 1)]
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Serialize, Deserialize, Hash)]
pub(crate) struct TaskLease {
owner: Uuid,
expiration: DateTime<Utc>,
}
impl_kv_value_revisioned!(TaskLease);
#[cfg(test)]
impl TaskLease {
pub(crate) fn new(owner: Uuid, expiration: DateTime<Utc>) -> Self {
Self {
owner,
expiration,
}
}
}
/// Represents the current status of a lease based on its expiration time.
///
/// The status determines whether a lease can be used as-is, needs renewal,
/// or has expired and can be replaced. Status is calculated by comparing
/// the current time against the lease's expiration time and duration.
///
/// # Variants
///
/// * `Valid` - The lease has more than half its duration remaining before expiration. The owning
/// node can continue using it without renewal.
///
/// * `Renewable` - The lease has less than half its duration remaining before expiration, but
/// hasn't expired yet. The owning node should renew it to maintain ownership.
///
/// * `Expired` - The lease's expiration time has passed. Any node can attempt to acquire a new
/// lease to replace it.
///
/// # Status Transitions
///
/// ```text
/// Valid (>50% remaining) → Renewable (0-50% remaining) → Expired (past expiration)
/// ```
#[derive(Debug)]
enum LeaseStatus {
Valid,
Renewable,
Expired,
}
/// Manages distributed task leases in a multi-node environment.
///
/// The LeaseHandler provides a mechanism for coordinating tasks across multiple
/// nodes by implementing a distributed lease system. This helps keep a single
/// node responsible for a task at a time, while allowing an in-flight batch to
/// complete even if the lease expires.
///
/// # Race Condition Prevention
///
/// The handler uses optimistic locking with conditional writes (putc) to prevent
/// race conditions when multiple nodes attempt to acquire the same lease simultaneously.
/// Only one node can successfully acquire a lease, even under high contention.
///
/// # Features
///
/// The handler uses a datastore to persist lease information, with built-in
/// support for:
/// - Lease acquisition with exponential backoff and jitter to handle contention
/// - Automatic lease expiration based on configurable duration
/// - Automatic lease renewal when more than half the duration has elapsed
/// - Lease ownership verification
#[derive(Clone)]
pub struct LeaseHandler {
sequences: Sequences,
/// UUID of the current node trying to acquire or check leases
node: Uuid,
/// Transaction factory for database operations
tf: TransactionFactory,
/// Type of task this lease handler is managing
task_type: TaskLeaseType,
/// How long each acquired lease should remain valid
lease_duration: Duration,
/// Optional task cancellation token checked before retries and lease operations
canceller: Option<CancellationToken>,
/// Unix timestamp (seconds) of the last lease maintenance check
last_maintain_check: Arc<AtomicI64>,
/// Maintenance period in seconds used to throttle lease checks
maintain_period: i64,
}
impl LeaseHandler {
/// Creates a new LeaseHandler for managing distributed task leases.
///
/// This constructor initializes a lease handler that will manage leases for a specific
/// task type using the provided transaction factory and lease duration.
///
/// The effective lease duration is clamped to a minimum of 8 seconds to prevent
/// excessive contention from very short lease durations.
///
/// # Arguments
/// * `sequences` - Sequence generator for transaction operations
/// * `node` - UUID of the current node that will attempt to acquire leases
/// * `tf` - Transaction factory for performing database operations
/// * `task_type` - The type of task this handler will manage leases for
/// * `lease_duration` - How long each acquired lease should remain valid before expiring
/// (minimum effective duration: 8 seconds)
///
/// # Returns
/// * `Ok(Self)` - A new LeaseHandler instance ready to manage leases
/// * `Err` - If the lease_duration cannot be converted to chrono::Duration (e.g., if it's too
/// large)
pub(super) fn new(
sequences: Sequences,
node: Uuid,
tf: TransactionFactory,
task_type: TaskLeaseType,
lease_duration: std::time::Duration,
) -> Result<Self> {
Self::new_inner(sequences, node, tf, task_type, lease_duration, None)
}
/// Creates a lease handler that stops retrying and maintaining leases when cancelled.
pub(super) fn new_with_canceller(
sequences: Sequences,
node: Uuid,
tf: TransactionFactory,
task_type: TaskLeaseType,
lease_duration: std::time::Duration,
canceller: CancellationToken,
) -> Result<Self> {
Self::new_inner(sequences, node, tf, task_type, lease_duration, Some(canceller))
}
fn new_inner(
sequences: Sequences,
node: Uuid,
tf: TransactionFactory,
task_type: TaskLeaseType,
lease_duration: std::time::Duration,
canceller: Option<CancellationToken>,
) -> Result<Self> {
Ok(Self {
sequences,
node,
tf,
task_type,
lease_duration: Duration::from_std(lease_duration)?.max(Duration::seconds(8)),
canceller,
last_maintain_check: Arc::new(AtomicI64::new(0)),
maintain_period: ((lease_duration.as_secs() / 8) as i64).max(1),
})
}
fn ensure_not_cancelled(&self) -> Result<()> {
if let Some(canceller) = &self.canceller
&& canceller.is_cancelled()
{
bail!(Error::QueryCancelled);
}
Ok(())
}
async fn sleep_or_cancelled(&self, duration: std::time::Duration) -> Result<()> {
if let Some(canceller) = &self.canceller {
tokio::select! {
_ = sleep(duration) => Ok(()),
_ = canceller.cancelled() => bail!(Error::QueryCancelled),
}
} else {
sleep(duration).await;
Ok(())
}
}
/// Attempts to acquire or check a lease for a specific task type.
///
/// This method tries to determine if the current node has a lease for the
/// specified task. It uses exponential backoff with jitter to handle
/// contention when multiple nodes attempt to acquire the same lease
/// simultaneously.
///
/// # Returns
/// * `Ok(true)` - If the node successfully acquired or already owns the lease
/// * `Ok(false)` - If another node owns the lease
/// * `Err` - If the operation timed out or encountered other errors
pub(super) async fn has_lease(&self) -> Result<bool> {
self.ensure_not_cancelled()?;
// Initial backoff time in milliseconds
let mut tempo = 4;
// Maximum backoff time in milliseconds before giving up
const MAX_BACKOFF: u64 = 32_768;
// Loop until we have a successful allocation or reach max backoff
// We use exponential backoff with a maximum limit to prevent infinite retries
let start = Instant::now();
while tempo < MAX_BACKOFF {
self.ensure_not_cancelled()?;
match self.check_lease().await {
Ok(r) => return Ok(r),
Err(e) if matches!(e.downcast_ref::<Error>(), Some(Error::QueryCancelled)) => {
return Err(e);
}
Err(e) => {
trace!("Tolerated error while getting a lease for {:?}: {e}", self.task_type);
}
}
// Apply exponential backoff with full jitter to reduce contention
// This randomizes sleep time between 1 and current tempo value
let sleep_ms = rand::rng().random_range(1..=tempo);
self.sleep_or_cancelled(std::time::Duration::from_millis(sleep_ms)).await?;
// Double the backoff time for next iteration
tempo *= 2;
}
// If we've reached maximum backoff without success, time out the operation
bail!(Error::QueryTimedout(start.elapsed().into()))
}
/// Attempts to maintain the current lease by checking and potentially renewing it.
///
/// This method is throttled: it performs an actual lease check at most once per maintenance
/// period (`lease_duration / 8`). When throttled (i.e., called again before the maintenance
/// period has elapsed), it returns `Ok(true)` immediately without contacting the datastore,
/// assuming the lease is still held.
///
/// When a check is performed, it provides lease ownership status to allow callers to decide
/// whether to continue processing or stop:
/// - **Continue processing**: Callers may choose to complete work already started even if the
/// lease is lost, preventing inconsistent state
/// - **Stop processing**: Callers may choose to abort immediately when losing the lease to
/// avoid duplicate work with another node
///
/// The initial lease check (via `has_lease()`) at the start of a task ensures only one node
/// begins processing. This method is for lease maintenance and ownership verification during
/// execution.
///
/// # Returns
/// * `Ok(true)` - The lease check was throttled (assumed still held), or the node successfully
/// maintained/renewed the lease
/// * `Ok(false)` - The node lost the lease to another node (or another node acquired it during
/// a race condition)
/// * `Err` - If database operations fail
///
/// # Example Usage
/// ```ignore
/// // Check lease before starting work
/// if !lease_handler.has_lease().await? {
/// return Ok(()); // Another node owns the lease
/// }
///
/// // Example 1: Continue processing regardless of lease status (complete work once started)
/// for item in items {
/// let _ = lease_handler.try_maintain_lease().await?; // Ignore lease ownership
/// process_item(item).await?;
/// }
///
/// // Example 2: Stop processing immediately if lease is lost
/// for item in items {
/// if !lease_handler.try_maintain_lease().await? {
/// return Ok(()); // Another node owns the lease now
/// }
/// process_item(item).await?;
/// }
/// ```
pub(crate) async fn try_maintain_lease(&self) -> Result<bool> {
self.ensure_not_cancelled()?;
let now = Utc::now();
let now_ts = now.timestamp();
let last = self.last_maintain_check.load(Ordering::Relaxed);
if (now_ts < last || now_ts - last > self.maintain_period)
&& self
.last_maintain_check
.compare_exchange(last, now_ts, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
// Check and potentially renew the lease, returning ownership status.
// Callers can use this information to decide whether to continue or stop processing.
self.ensure_not_cancelled()?;
return self.check_lease().await;
}
Ok(true)
}
/// Checks if a lease exists and attempts to acquire or renew it.
///
/// This method performs the actual lease checking, acquisition, and renewal logic:
/// 1. Checks if there's an existing lease in the datastore
/// 2. If another node owns a non-expired lease, returns false
/// 3. If this node owns the lease and it's still valid (more than half duration remaining),
/// returns true without renewal
/// 4. If this node owns the lease and it's renewable (less than half duration remaining),
/// attempts to renew it
/// 5. If no lease exists or it has expired, attempts to acquire a new lease
///
/// # Returns
/// * `Ok(true)` - If the node successfully acquired, renewed, or already owns a valid lease
/// * `Ok(false)` - If another node owns the lease
/// * `Err` - If database operations fail
async fn check_lease(&self) -> Result<bool> {
self.ensure_not_cancelled()?;
let now = Utc::now();
// First check if there's already a valid lease
if let Some((current_lease, lease_status)) = self.read_lease(now).await? {
// If another node owns a non-expired lease, we cannot acquire it
if current_lease.owner != self.node && !matches!(lease_status, LeaseStatus::Expired) {
return Ok(false);
}
// If we own the lease and it's still valid (more than half the lease duration
// remaining), we can continue using it without renewal
if matches!(lease_status, LeaseStatus::Valid) {
return Ok(true);
}
// If we reach here, we own the lease but it's in Renewable state (close to expiring),
// so we'll try to acquire a new lease below to extend it
}
// Acquire a new lease - this handles three scenarios:
// 1. No lease exists yet
// 2. An expired lease exists (any owner)
// 3. We own a renewable lease that needs to be extended
self.acquire_new_lease(now).await
}
/// Convenience wrapper that creates a read-only transaction and retrieves the lease.
///
/// This method is used when checking lease status without intending to modify it.
/// It creates a read transaction and delegates to `get_lease()` for the actual retrieval.
///
/// # Arguments
/// * `current` - The current timestamp to use for determining lease status
///
/// # Returns
/// * `Ok(Some((lease, status)))` - If a lease exists, returns it with its current status
/// * `Ok(None)` - If no lease exists for this task type
/// * `Err` - If database operations fail
async fn read_lease(&self, current: DateTime<Utc>) -> Result<Option<(TaskLease, LeaseStatus)>> {
self.ensure_not_cancelled()?;
let tx = self
.tf
.transaction(TransactionType::Read, LockType::Optimistic, self.sequences.clone())
.await?;
if let Err(e) = self.ensure_not_cancelled() {
let _ = tx.cancel().await;
return Err(e);
}
self.get_lease(&tx, current).await
}
/// Retrieves the current lease from the datastore and determines its status.
///
/// This method fetches the lease for the handler's task type and calculates its status
/// by comparing the current time against the lease's expiration time and duration:
///
/// - **Valid**: More than 50% of the lease duration remains (no action needed)
/// - **Renewable**: Between 0% and 50% of the lease duration remains (should renew soon)
/// - **Expired**: Past the expiration time (can be replaced by any node)
///
/// # Arguments
/// * `tx` - The transaction to use for database operations
/// * `current` - The current timestamp to use for determining lease status
///
/// # Returns
/// * `Ok(Some((lease, status)))` - If a lease exists, returns it with its calculated status
/// * `Ok(None)` - If no lease exists for this task type
/// * `Err` - If database operations fail
async fn get_lease(
&self,
tx: &Transaction,
current: DateTime<Utc>,
) -> Result<Option<(TaskLease, LeaseStatus)>> {
if let Some(lease) = tx.get(&Tl::new(&self.task_type), None).await? {
let status = if current > lease.expiration {
LeaseStatus::Expired
} else if current > lease.expiration - self.lease_duration / 2 {
LeaseStatus::Renewable
} else {
LeaseStatus::Valid
};
Ok(Some((lease, status)))
} else {
Ok(None)
}
}
/// Attempts to acquire a new lease for the current node using atomic conditional writes.
///
/// This method implements the core lease acquisition logic with race condition protection.
/// It uses optimistic locking with conditional writes to ensure that only one node can
/// successfully acquire a lease at a time, even when multiple nodes attempt acquisition
/// simultaneously.
///
/// # Race Condition Handling
///
/// When multiple nodes attempt to acquire or renew a lease simultaneously, the conditional
/// write (`putc`) may fail with `TxConditionNotMet` if another node wrote to the lease
/// between our read and write operations. This is gracefully handled by returning `Ok(false)`
/// rather than propagating an error, allowing ongoing tasks to continue processing while
/// acknowledging that another node now owns the lease.
///
/// # Returns
/// * `Ok(true)` - If the lease was successfully acquired by this node
/// * `Ok(false)` - If another node owns a valid lease or acquired it during our attempt
/// * `Err` - Only if database operations fail (network errors, transaction failures, etc.)
async fn acquire_new_lease(&self, current: DateTime<Utc>) -> Result<bool> {
self.ensure_not_cancelled()?;
let tx = self
.tf
.transaction(TransactionType::Write, LockType::Optimistic, self.sequences.clone())
.await?;
if let Err(e) = self.ensure_not_cancelled() {
let _ = tx.cancel().await;
return Err(e);
}
// Re-check within the write transaction: if another node owns a non-expired lease,
// return early without attempting to write. This avoids unnecessary write attempts
// when the lease state changed between the initial read and this write transaction.
let previous_lease = match self.get_lease(&tx, current).await {
Ok(Some((current_lease, current_status))) => {
if current_lease.owner != self.node
&& !matches!(current_status, LeaseStatus::Expired)
{
let _ = tx.cancel().await;
return Ok(false);
}
Some(current_lease)
}
Ok(None) => None,
Err(e) => {
let _ = tx.cancel().await;
return Err(e);
}
};
let new_lease = TaskLease {
owner: self.node,
expiration: current + self.lease_duration,
};
if let Err(e) = self.ensure_not_cancelled() {
let _ = tx.cancel().await;
return Err(e);
}
// Use putc() (conditional put) to atomically write the lease ONLY if the current value
// matches what we read earlier. This prevents race conditions:
// - If previous_lease is None: writes succeed only if the key still doesn't exist
// - If previous_lease is Some(expired): writes succeed only if the value hasn't changed
// - If another node wrote between our get() and putc(): condition fails with
// TxConditionNotMet
//
// This ensures mutual exclusion: only one node can successfully acquire the lease when
// multiple nodes attempt acquisition simultaneously (e.g., when replacing an expired
// lease).
let res = tx.putc(&Tl::new(&self.task_type), &new_lease, previous_lease.as_ref()).await;
match res {
Ok(()) => {
tx.commit().await?;
Ok(true)
}
// CRITICAL: Convert TxConditionNotMet to Ok(false) rather than propagating as an error.
// This race condition occurs when another node acquires/renews the lease between our
// read and write operations. By returning Ok(false) instead of Err, we enable the
// best-effort lease maintenance behavior: try_maintain_lease() can ignore the result
// and allow ongoing tasks to complete even when another node takes over the lease.
// This prevents tasks from aborting mid-process when lease ownership changes.
Err(e) => {
tx.cancel().await?;
if matches!(
e.downcast_ref::<Error>(),
Some(Error::Kvs(KvsError::TransactionConditionNotMet))
) {
Ok(false)
} else {
Err(e)
}
}
}
}
}
#[cfg(test)]
#[cfg(any(feature = "kv-rocksdb", feature = "kv-mem"))]
mod tests {
use std::sync::Arc;
#[cfg(feature = "kv-mem")]
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
#[cfg(feature = "kv-mem")]
use chrono::Utc;
#[cfg(feature = "kv-rocksdb")]
use temp_dir::TempDir;
use tokio::sync::Notify;
#[cfg(feature = "kv-mem")]
use tokio::time::sleep;
use uuid::Uuid;
use crate::kvs::ds::{DatastoreFlavor, TransactionFactory};
use crate::kvs::sequences::Sequences;
#[cfg(feature = "kv-mem")]
use crate::kvs::tasklease::LeaseStatus;
use crate::kvs::tasklease::{LeaseHandler, TaskLeaseType};
/// Tracks the results of lease acquisition attempts by a node.
///
/// This struct collects statistics about the outcomes of multiple lease
/// acquisition attempts:
/// * `ok_true` - Count of successful lease acquisitions (node owns the lease)
/// * `ok_false` - Count of failed lease acquisitions (another node owns the lease)
/// * `err` - Count of errors encountered during lease acquisition attempts
#[derive(Default)]
struct NodeResult {
ok_true: usize,
ok_false: usize,
err: usize,
}
/// Simulates a node repeatedly attempting to acquire a lease for a
/// specified duration.
///
/// This function represents a single node in a distributed system that
/// continuously tries to acquire a task lease for the IndexCompaction
/// task. It runs for a fixed duration and collects statistics on the
/// outcomes of each attempt.
///
/// # Parameters
/// * `id` - UUID identifying the node
/// * `tf` - Transaction factory for database operations
/// * `test_duration` - How long the node should run and attempt to acquire leases
/// * `lease_duration` - How long each acquired lease should be valid
///
/// # Returns
/// A `NodeResult` containing statistics about the lease acquisition
/// attempts:
/// * How many times the node successfully acquired the lease
/// * How many times the node failed to acquire the lease (owned by another node)
/// * How many errors occurred during lease acquisition attempts
async fn node_task_lease(
sequences: Sequences,
id: Uuid,
tf: TransactionFactory,
test_duration: Duration,
lease_duration: Duration,
) -> NodeResult {
let lh =
LeaseHandler::new(sequences, id, tf, TaskLeaseType::IndexCompaction, lease_duration)
.unwrap();
let mut result = NodeResult::default();
let start_time = Instant::now();
while start_time.elapsed() < test_duration {
match lh.has_lease().await {
Ok(true) => {
result.ok_true += 1;
}
Ok(false) => {
result.ok_false += 1;
}
Err(_e) => {
result.err += 1;
}
}
}
result
}
/// Tests the task lease mechanism with multiple concurrent nodes.
///
/// This function simulates a distributed environment where multiple nodes
/// compete for the same task lease. It creates three concurrent nodes,
/// each with a unique ID, and has them all attempt to acquire the same
/// lease repeatedly for a fixed duration.
///
/// The test verifies that:
/// 1. At least one node successfully acquires the lease at some point
/// 2. At least one node fails to acquire the lease at some point (because another node owns it)
/// 3. No errors occur during the lease acquisition process
///
/// # Parameters
/// * `flavor` - The type of datastore to use for the test (memory or RocksDB)
async fn task_lease_concurrency(flavor: DatastoreFlavor) {
// Async event trigger
let async_event_trigger = Arc::new(Notify::new());
// Create a transaction factory with the specified datastore flavor
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
// Create a sequence generator for the transaction factory
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
// Set test to run for 3 seconds
let test_duration = Duration::from_secs(3);
// Set each lease to be valid for 1 second
let lease_duration = Duration::from_secs(1);
// Create a closure that generates a node task with a specific UUID
let new_node = |n| {
node_task_lease(
sequences.clone(),
Uuid::from_u128(n),
tf.clone(),
test_duration,
lease_duration,
)
};
// Spawn three concurrent nodes with different UUIDs
let node1 = tokio::spawn(new_node(0));
let node2 = tokio::spawn(new_node(1));
let node3 = tokio::spawn(new_node(2));
// Wait for all nodes to complete and collect their results
let (res1, res2, res3) = tokio::try_join!(node1, node2, node3).expect("Tasks failed");
// Verify that at least one node successfully acquired the lease
assert!(res1.ok_true + res2.ok_true + res3.ok_true > 0);
// Verify that at least one node failed to acquire the lease (another node owned
// it)
assert!(res1.ok_false + res2.ok_false + res3.ok_false > 0);
// Verify that no errors occurred during lease acquisition
assert_eq!(res1.err + res2.err + res3.err, 0);
}
/// Tests the task lease concurrency mechanism using an in-memory datastore.
///
/// This test creates an in-memory datastore and runs the task lease
/// concurrency test to verify that the lease mechanism works correctly in
/// a multi-threaded environment with an in-memory storage backend.
///
/// The test is only compiled and run when the "kv-mem" feature is enabled.
#[cfg(feature = "kv-mem")]
#[tokio::test(flavor = "multi_thread")]
async fn task_lease_concurrency_memory() {
// Create a new memory configuration
let config = crate::kvs::mem::MemoryConfig::default();
// Create a new in-memory datastore
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
// Run the concurrency test with the in-memory datastore
task_lease_concurrency(flavor).await;
}
/// Tests the task lease concurrency mechanism using a RocksDB datastore.
///
/// This test creates a temporary RocksDB datastore and runs the task lease
/// concurrency test to verify that the lease mechanism works correctly in
/// a multi-threaded environment with a persistent storage backend.
///
/// The test is only compiled and run when the "kv-rocksdb" feature is
/// enabled.
#[cfg(feature = "kv-rocksdb")]
#[tokio::test(flavor = "multi_thread")]
async fn task_lease_concurrency_rocksdb() {
// Create a temporary directory for the RocksDB datastore
let path = TempDir::new().unwrap().path().to_string_lossy().to_string();
// Create a new RocksDB configuration
let config = crate::kvs::rocksdb::RocksDbConfig::default();
// Create a new RocksDB datastore in the temporary directory
let flavor = crate::kvs::rocksdb::Datastore::new(&path, config)
.await
.map(DatastoreFlavor::RocksDB)
.unwrap();
// Run the concurrency test with the RocksDB datastore
task_lease_concurrency(flavor).await;
}
/// Tests the task lease concurrency mechanism using a SurrealKV datastore.
///
/// This test creates a temporary SurrealKV datastore and runs the task lease
/// concurrency test to verify that the lease mechanism works correctly in
/// a multi-threaded environment with a persistent storage backend.
///
/// The test is only compiled and run when the "kv-surrealkv" feature is
/// enabled.
#[cfg(feature = "kv-surrealkv")]
#[tokio::test(flavor = "multi_thread")]
async fn task_lease_concurrency_surrealkv() {
// Create a temporary directory for the SurrealKV datastore
use temp_dir::TempDir;
let path = TempDir::new().unwrap().path().to_string_lossy().to_string();
// Create a new SurrealKV configuration
let config = crate::kvs::surrealkv::SurrealKvConfig::default();
// Create a new SurrealKV datastore
let flavor = crate::kvs::surrealkv::Datastore::new(&path, config)
.await
.map(DatastoreFlavor::SurrealKV)
.unwrap();
// Run the concurrency test with the SurrealKV datastore
task_lease_concurrency(flavor).await;
}
/// Tests the lease renewal behavior when a node already owns a lease.
///
/// This test verifies that:
/// 1. A node that owns a lease doesn't try to re-acquire it if more than half the lease
/// duration remains (status: Valid)
/// 2. After waiting for more than half the lease duration, the lease enters the Renewable state
/// and `check_lease()` triggers a renewal
/// 3. After renewal, the lease is Valid again with a later expiration time
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_lease_renewal_behavior() {
// Create a new memory configuration
let config = crate::kvs::mem::MemoryConfig::default();
// Create an in-memory datastore
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
// Create an async event trigger
let async_event_trigger = Arc::new(Notify::new());
// Create the transaction factory
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
// Set lease duration to 10 seconds
let lease_duration = Duration::from_secs(10);
let node_id = Uuid::new_v4();
// Create a lease handler
let lh = LeaseHandler::new(
sequences,
node_id,
tf,
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
// PART 1: Initial lease acquisition
// Initially acquire the lease
let has_lease = lh.check_lease().await.unwrap();
assert!(has_lease, "Should successfully acquire the lease initially");
let (initial_lease, status) = lh.read_lease(Utc::now()).await.unwrap().unwrap();
assert_eq!(initial_lease.owner, node_id);
assert!(
matches!(status, LeaseStatus::Valid),
"Lease should be initially valid (not expired and not renewable) - {status:?}"
);
// PART 2: Verify no renewal when more than half duration remains
// Check again immediately - should return true without re-acquiring
let has_lease = lh.check_lease().await.unwrap();
assert!(has_lease, "Should still have the lease without re-acquiring");
let (lease, status) = lh.read_lease(Utc::now()).await.unwrap().unwrap();
// Verify the status
assert!(
matches!(status, LeaseStatus::Valid),
"Lease should still be valid (not expired and not renewable) - {status:?}"
);
// Verify the expiration hasn't changed (no renewal)
assert_eq!(lease.expiration, initial_lease.expiration);
// PART 3: Verify the condition for renewal
// Wait half the lease duration
sleep(Duration::from_secs(7)).await;
// Get the current lease status
let (_, status) = lh.read_lease(Utc::now()).await.unwrap().unwrap();
assert!(matches!(status, LeaseStatus::Renewable), "Lease should be renewable - {status:?}");
// Now trigger the renewal
let has_lease = lh.check_lease().await.unwrap();
assert!(has_lease, "Should still have the lease after renewal");
// Get the renewed lease
let (new_lease, status) = lh.read_lease(Utc::now()).await.unwrap().unwrap();
assert!(matches!(status, LeaseStatus::Valid), "Lease should be valid - {status:?}");
assert!(new_lease.expiration > initial_lease.expiration, "Lease should have been renewed");
}
/// Tests that another node cannot acquire a lease while a valid lease is held.
///
/// This test verifies the mutual exclusion property of the lease system:
/// 1. Node A acquires a lease successfully
/// 2. Node B attempts to acquire the same lease type and is rejected
/// 3. Node A can still confirm it holds the lease
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_another_node_rejected_while_lease_valid() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
let lease_duration = Duration::from_secs(60);
let node_a = Uuid::from_u128(1);
let node_b = Uuid::from_u128(2);
// Node A acquires the lease
let lh_a = LeaseHandler::new(
sequences.clone(),
node_a,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
let acquired = lh_a.check_lease().await.unwrap();
assert!(acquired, "Node A should acquire the lease");
// Node B tries to acquire the same lease type
let lh_b = LeaseHandler::new(
sequences.clone(),
node_b,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
let acquired = lh_b.check_lease().await.unwrap();
assert!(!acquired, "Node B should be rejected while Node A holds a valid lease");
// Node A still holds the lease
let acquired = lh_a.check_lease().await.unwrap();
assert!(acquired, "Node A should still hold the lease");
// Verify the lease is owned by Node A
let (lease, status) = lh_a.read_lease(Utc::now()).await.unwrap().unwrap();
assert_eq!(lease.owner, node_a);
assert!(matches!(status, LeaseStatus::Valid), "Lease should be valid - {status:?}");
}
/// Tests that the minimum lease duration floor of 8 seconds is enforced.
///
/// When a very short lease duration (e.g., 1 second) is provided, the effective
/// duration should be clamped to 8 seconds. This prevents excessive contention
/// from misconfigured short leases.
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_min_lease_duration_floor() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
// Create a handler with a 1-second lease duration (below the 8-second floor)
let lh = LeaseHandler::new(
sequences,
Uuid::new_v4(),
tf,
TaskLeaseType::IndexCompaction,
Duration::from_secs(1),
)
.unwrap();
// The effective lease duration should be clamped to 8 seconds
assert_eq!(
lh.lease_duration,
chrono::Duration::seconds(8),
"Lease duration should be clamped to the 8-second minimum"
);
// Acquire a lease and verify the expiration reflects the 8-second floor
lh.check_lease().await.unwrap();
let now = Utc::now();
let (lease, _) = lh.read_lease(now).await.unwrap().unwrap();
// The expiration should be approximately 8 seconds from when it was acquired
let remaining = lease.expiration - now;
assert!(
remaining > chrono::Duration::seconds(6),
"Lease expiration should reflect the 8-second floor, but remaining is {remaining}"
);
}
/// Tests that different task types maintain independent leases.
///
/// Two handlers managing different task types should not interfere with each other.
/// Both should be able to acquire leases simultaneously, even for the same node.
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_different_task_types_are_independent() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
let lease_duration = Duration::from_secs(60);
let node_a = Uuid::from_u128(1);
let node_b = Uuid::from_u128(2);
// Node A acquires a lease for IndexCompaction
let lh_a = LeaseHandler::new(
sequences.clone(),
node_a,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
let acquired = lh_a.check_lease().await.unwrap();
assert!(acquired, "Node A should acquire IndexCompaction lease");
// Node B acquires a lease for ChangeFeedCleanup (different task type)
let lh_b = LeaseHandler::new(
sequences.clone(),
node_b,
tf.clone(),
TaskLeaseType::ChangeFeedCleanup,
lease_duration,
)
.unwrap();
let acquired = lh_b.check_lease().await.unwrap();
assert!(acquired, "Node B should acquire ChangeFeedCleanup lease independently");
// Both leases should still be valid
let (lease_a, _) = lh_a.read_lease(Utc::now()).await.unwrap().unwrap();
let (lease_b, _) = lh_b.read_lease(Utc::now()).await.unwrap().unwrap();
assert_eq!(lease_a.owner, node_a);
assert_eq!(lease_b.owner, node_b);
}
/// Tests the throttling behavior of `try_maintain_lease`.
///
/// This test verifies that:
/// 1. The first call to `try_maintain_lease` performs an actual lease check
/// 2. Subsequent calls within the maintenance period are throttled and return `Ok(true)`
/// without contacting the datastore
/// 3. The `last_maintain_check` timestamp is updated on the first call but not on throttled
/// calls
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_try_maintain_lease_throttling() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
// Use a 60-second lease so maintain_period = 60/8 = 7 seconds
let lease_duration = Duration::from_secs(60);
let node_id = Uuid::new_v4();
let lh = LeaseHandler::new(
sequences,
node_id,
tf,
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
// First, acquire the lease
let acquired = lh.check_lease().await.unwrap();
assert!(acquired, "Should acquire the lease");
// The last_maintain_check starts at 0 (never checked)
// First call to try_maintain_lease should perform an actual check
let result = lh.try_maintain_lease().await.unwrap();
assert!(result, "First try_maintain_lease should succeed");
// Record the timestamp after the first maintain call
let first_check_ts = lh.last_maintain_check.load(Ordering::Relaxed);
assert!(first_check_ts > 0, "last_maintain_check should be updated after first call");
// Subsequent calls within the maintenance period should be throttled
// (maintain_period = 7 seconds, and we're calling immediately)
let result = lh.try_maintain_lease().await.unwrap();
assert!(result, "Throttled try_maintain_lease should return Ok(true)");
// The timestamp should not change on throttled calls
let second_check_ts = lh.last_maintain_check.load(Ordering::Relaxed);
assert_eq!(
first_check_ts, second_check_ts,
"last_maintain_check should not change on throttled calls"
);
// Call many times rapidly - all should be throttled and return Ok(true)
for _ in 0..100 {
let result = lh.try_maintain_lease().await.unwrap();
assert!(result, "All throttled calls should return Ok(true)");
}
}
/// Tests the full lease lifecycle including expiration and takeover by another node.
///
/// This test verifies that:
/// 1. Node A acquires a lease
/// 2. Node B is rejected while the lease is valid
/// 3. After the lease expires, the status transitions to Expired
/// 4. Node B can then successfully acquire the expired lease
/// 5. Node A is now rejected when trying to re-acquire
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_lease_expiration_and_takeover() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
// Use the minimum lease duration (8 seconds due to the floor)
let lease_duration = Duration::from_secs(1); // Will be clamped to 8 seconds
let node_a = Uuid::from_u128(1);
let node_b = Uuid::from_u128(2);
let lh_a = LeaseHandler::new(
sequences.clone(),
node_a,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
let lh_b = LeaseHandler::new(
sequences.clone(),
node_b,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
// Node A acquires the lease
let acquired = lh_a.check_lease().await.unwrap();
assert!(acquired, "Node A should acquire the lease");
// Node B is rejected
let acquired = lh_b.check_lease().await.unwrap();
assert!(!acquired, "Node B should be rejected while Node A holds the lease");
// Wait for the lease to expire (8 seconds + small margin)
sleep(Duration::from_secs(9)).await;
// Verify the lease is now expired
let (_, status) = lh_a.read_lease(Utc::now()).await.unwrap().unwrap();
assert!(
matches!(status, LeaseStatus::Expired),
"Lease should be expired after waiting - {status:?}"
);
// Node B can now acquire the expired lease
let acquired = lh_b.check_lease().await.unwrap();
assert!(acquired, "Node B should acquire the expired lease");
// Verify Node B now owns the lease
let (lease, status) = lh_b.read_lease(Utc::now()).await.unwrap().unwrap();
assert_eq!(lease.owner, node_b, "Node B should now own the lease");
assert!(matches!(status, LeaseStatus::Valid), "New lease should be valid - {status:?}");
// Node A should now be rejected
let acquired = lh_a.check_lease().await.unwrap();
assert!(!acquired, "Node A should be rejected now that Node B owns the lease");
}
/// Tests that `try_maintain_lease` returns `Ok(false)` when another node has taken the lease.
///
/// This verifies that the lease ownership status is correctly propagated through
/// `try_maintain_lease` when an actual check is performed and the lease is no longer
/// owned by the current node.
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_try_maintain_lease_reports_lost_lease() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
// Use minimum lease duration (clamped to 8 seconds)
let lease_duration = Duration::from_secs(1);
let node_a = Uuid::from_u128(1);
let node_b = Uuid::from_u128(2);
let lh_a = LeaseHandler::new(
sequences.clone(),
node_a,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
let lh_b = LeaseHandler::new(
sequences.clone(),
node_b,
tf.clone(),
TaskLeaseType::IndexCompaction,
lease_duration,
)
.unwrap();
// Node A acquires the lease
let acquired = lh_a.check_lease().await.unwrap();
assert!(acquired, "Node A should acquire the lease");
// Node A's try_maintain_lease should succeed
let result = lh_a.try_maintain_lease().await.unwrap();
assert!(result, "Node A should maintain the lease");
// Wait for the lease to expire
sleep(Duration::from_secs(9)).await;
// Node B acquires the expired lease
let acquired = lh_b.check_lease().await.unwrap();
assert!(acquired, "Node B should acquire the expired lease");
// Force Node A's throttle to allow a real check by advancing last_maintain_check
// far into the past
lh_a.last_maintain_check.store(0, Ordering::Relaxed);
// Node A's try_maintain_lease should now report the lease is lost
let result = lh_a.try_maintain_lease().await.unwrap();
assert!(!result, "Node A should detect that it lost the lease");
}
/// Tests that a lease created with no prior state works correctly.
///
/// This verifies the `acquire_new_lease` path when no lease exists in the datastore
/// (previous_lease is None), ensuring the `putc` conditional write handles the
/// "key doesn't exist" case correctly.
#[cfg(feature = "kv-mem")]
#[tokio::test]
async fn test_initial_lease_acquisition_from_empty_state() {
let config = crate::kvs::mem::MemoryConfig::default();
let flavor =
crate::kvs::mem::Datastore::new(config).await.map(DatastoreFlavor::Mem).unwrap();
let async_event_trigger = Arc::new(Notify::new());
let tf = TransactionFactory::new(
async_event_trigger,
Box::new(flavor),
Arc::new(Default::default()),
);
let sequences = Sequences::new(tf.clone(), Uuid::new_v4());
let node_id = Uuid::new_v4();
let lh = LeaseHandler::new(
sequences.clone(),
node_id,
tf.clone(),
TaskLeaseType::IndexCompaction,
Duration::from_secs(60),
)
.unwrap();
// Verify no lease exists initially
let result = lh.read_lease(Utc::now()).await.unwrap();
assert!(result.is_none(), "No lease should exist initially");
// Acquire the lease from empty state
let acquired = lh.check_lease().await.unwrap();
assert!(acquired, "Should acquire lease from empty state");
// Verify the lease now exists with correct owner
let (lease, status) = lh.read_lease(Utc::now()).await.unwrap().unwrap();
assert_eq!(lease.owner, node_id, "Lease owner should match the node");
assert!(matches!(status, LeaseStatus::Valid), "New lease should be valid - {status:?}");
// Verify has_lease also works from empty state (using a different task type)
let lh2 = LeaseHandler::new(
sequences,
node_id,
tf,
TaskLeaseType::EventProcessing,
Duration::from_secs(60),
)
.unwrap();
let acquired = lh2.has_lease().await.unwrap();
assert!(acquired, "has_lease should acquire from empty state");
}
}