Skip to main content

tsoracle_core/
allocator.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//
8//  Copyright (c) 2026 Prisma Risk
9//  Licensed under the Apache License, Version 2.0
10//  https://github.com/prisma-risk/tsoracle
11//
12
13// #[PerformanceCriticalPath]
14//! The window allocator state machine. Sync, no I/O.
15
16use crate::{Epoch, LOGICAL_MAX, PHYSICAL_MS_MAX, Timestamp};
17
18/// A contiguous block of `count` timestamps starting at
19/// `(physical_ms, logical_start)`, all sharing one leadership `epoch`.
20///
21/// Fields are private and the only public constructor is
22/// [`try_new`](Self::try_new), which validates that every timestamp the grant
23/// covers fits the packed 46-bit physical / 18-bit logical layout. A value of
24/// this type is therefore proof that [`first`](Self::first) and
25/// [`last`](Self::last) can pack without panicking — the in-range invariant is
26/// guaranteed by the type, not by the one constructor that happens to build it.
27#[derive(Copy, Clone, Debug, PartialEq, Eq)]
28pub struct WindowGrant {
29    physical_ms: u64,
30    logical_start: u32,
31    count: u32,
32    epoch: Epoch,
33}
34
35impl WindowGrant {
36    /// Construct a grant, checking that every timestamp it covers packs
37    /// cleanly. This is the only way to build a `WindowGrant` outside this
38    /// module, so a constructed value witnesses that `first`/`last` are
39    /// infallible.
40    ///
41    /// Rejects `count == 0` (a grant covers at least one timestamp, and
42    /// `last`'s `logical_start + count - 1` would underflow). The range check
43    /// defers to [`Timestamp::try_pack`] on the *last* logical the grant emits:
44    /// it is the single source of truth for the bit layout, and since
45    /// `logical_start <= last_logical <= LOGICAL_MAX` validating the last
46    /// boundary validates the first by implication.
47    pub fn try_new(
48        physical_ms: u64,
49        logical_start: u32,
50        count: u32,
51        epoch: Epoch,
52    ) -> Result<Self, CoreError> {
53        if count == 0 {
54            return Err(CoreError::InvalidCount(0));
55        }
56        let last_logical =
57            logical_start
58                .checked_add(count - 1)
59                .ok_or(CoreError::LogicalRangeOutOfRange {
60                    logical_start,
61                    count,
62                })?;
63        Timestamp::try_pack(physical_ms, last_logical).map_err(|err| match err {
64            crate::TimestampError::PhysicalMsOutOfRange { physical_ms, .. } => {
65                CoreError::PhysicalMsOutOfRange(physical_ms)
66            }
67            crate::TimestampError::LogicalOutOfRange { .. } => CoreError::LogicalRangeOutOfRange {
68                logical_start,
69                count,
70            },
71        })?;
72        Ok(WindowGrant {
73            physical_ms,
74            logical_start,
75            count,
76            epoch,
77        })
78    }
79
80    pub fn physical_ms(&self) -> u64 {
81        self.physical_ms
82    }
83    pub fn logical_start(&self) -> u32 {
84        self.logical_start
85    }
86    pub fn count(&self) -> u32 {
87        self.count
88    }
89    pub fn epoch(&self) -> Epoch {
90        self.epoch
91    }
92
93    /// The first timestamp in the grant. Infallible: [`try_new`](Self::try_new)
94    /// validated `(physical_ms, logical_start)` is in range, so `pack` cannot
95    /// trip its `assert!`.
96    pub fn first(&self) -> Timestamp {
97        Timestamp::pack(self.physical_ms, self.logical_start)
98    }
99    /// The last timestamp in the grant. Infallible: [`try_new`](Self::try_new)
100    /// validated `(physical_ms, logical_start + count - 1)` is in range (and
101    /// `count >= 1`, so the subtraction cannot underflow), so `pack` cannot
102    /// trip its `assert!`.
103    pub fn last(&self) -> Timestamp {
104        Timestamp::pack(self.physical_ms, self.logical_start + self.count - 1)
105    }
106}
107
108#[derive(Debug, thiserror::Error, PartialEq, Eq)]
109pub enum CoreError {
110    #[error("not leader")]
111    NotLeader,
112    #[error("window exhausted; caller must extend before retrying")]
113    WindowExhausted,
114    #[error("invalid count: {0}")]
115    InvalidCount(u32),
116    #[error("physical_ms {0} exceeds 46-bit maximum")]
117    PhysicalMsOutOfRange(u64),
118    #[error("logical range [{logical_start}, +{count}) exceeds the 18-bit logical field")]
119    LogicalRangeOutOfRange { logical_start: u32, count: u32 },
120    #[error(
121        "invalid leadership window: fence_floor {fence_floor} exceeds committed_ceiling {committed_ceiling}"
122    )]
123    InvalidLeadershipWindow {
124        fence_floor: u64,
125        committed_ceiling: u64,
126    },
127    #[error(
128        "window extension overflow: max(floor {floor}, now_ms {now_ms}) + ahead_ms {ahead_ms} exceeds u64::MAX"
129    )]
130    WindowExtensionOverflow {
131        floor: u64,
132        now_ms: u64,
133        ahead_ms: u64,
134    },
135}
136
137/// The result of a `try_commit_window_extension` that passed range validation.
138///
139/// A commit either raises the durable bound or is dropped for one of three
140/// benign, expected reasons. Collapsing both into `Ok(())` left a caller that
141/// just paid for a `persist_high_water` round-trip unable to tell "I raised the
142/// bound" from "I silently dropped your durably-persisted value." This type
143/// preserves the distinction so the server can log/metric the dropped commits —
144/// a leading indicator of epoch churn or persist reordering.
145#[derive(Copy, Clone, Debug, PartialEq, Eq)]
146pub enum CommitOutcome {
147    /// The durable bound advanced to this value.
148    Applied(u64),
149    /// The bound did not move; see [`IgnoreReason`] for why.
150    Ignored(IgnoreReason),
151}
152
153/// Why a [`CommitOutcome::Ignored`] commit left the durable bound unchanged.
154///
155/// All three are benign and expected under normal failover: the epoch-fencing
156/// design of `try_commit_window_extension` deliberately drops late commits from
157/// a superseded epoch rather than erroring, and the monotonic bound rejects a
158/// value that does not advance. They are kept apart so a caller can distinguish
159/// epoch churn ([`NotLeader`](Self::NotLeader) / [`EpochMismatch`](Self::EpochMismatch))
160/// from persist reordering ([`NotAdvanced`](Self::NotAdvanced)).
161#[derive(Copy, Clone, Debug, PartialEq, Eq)]
162pub enum IgnoreReason {
163    /// The allocator is no longer a leader, so the commit has no window to raise.
164    NotLeader,
165    /// The allocator leads a different epoch than the commit targeted; the
166    /// commit is a late persist from a superseded epoch, fenced out.
167    EpochMismatch { expected: Epoch, current: Epoch },
168    /// The allocator still leads the targeted epoch, but the persisted value did
169    /// not exceed the current bound, so the monotonic bound rejects it.
170    NotAdvanced { persisted: u64, committed: u64 },
171}
172
173#[derive(Debug)]
174enum State {
175    NotLeader,
176    Leader {
177        epoch: Epoch,
178        /// Persisted upper bound: the allocator will not issue any timestamp with
179        /// `physical_ms` greater than this without a fresh `try_commit_window_extension`.
180        committed_high_water: u64,
181        /// Next `physical_ms` we are willing to issue at. Initialized to
182        /// `fence_floor` on leadership gain, then advances monotonically — never
183        /// retreats below the fence even when `now_ms` is a past value.
184        next_physical_ms: u64,
185        /// Next logical counter within `next_physical_ms`.
186        next_logical: u32,
187    },
188}
189
190pub struct Allocator {
191    state: State,
192}
193
194impl Allocator {
195    pub fn new() -> Self {
196        Allocator {
197            state: State::NotLeader,
198        }
199    }
200
201    /// Seed the allocator once the failover fence has durably persisted both
202    /// the floor and the pre-extended ceiling.
203    ///
204    /// `fence_floor` is the first `physical_ms` the new leader may issue —
205    /// the server sets it to `prior_high_water + 1` so the new leader's
206    /// timestamps are strictly above any the prior leader could have issued.
207    ///
208    /// `committed_ceiling` is the pre-extended upper bound the server has
209    /// already persisted (typically `fence_floor + window_ms`). It must
210    /// satisfy `committed_ceiling >= fence_floor` so the allocator can serve
211    /// `try_grant` immediately without an additional extension round-trip.
212    pub fn try_on_leadership_gained(
213        &mut self,
214        fence_floor: u64,
215        committed_ceiling: u64,
216        epoch: Epoch,
217    ) -> Result<(), CoreError> {
218        if fence_floor > PHYSICAL_MS_MAX {
219            return Err(CoreError::PhysicalMsOutOfRange(fence_floor));
220        }
221        if committed_ceiling > PHYSICAL_MS_MAX {
222            return Err(CoreError::PhysicalMsOutOfRange(committed_ceiling));
223        }
224        if committed_ceiling < fence_floor {
225            return Err(CoreError::InvalidLeadershipWindow {
226                fence_floor,
227                committed_ceiling,
228            });
229        }
230        self.state = State::Leader {
231            epoch,
232            committed_high_water: committed_ceiling,
233            next_physical_ms: fence_floor,
234            next_logical: 0,
235        };
236        Ok(())
237    }
238
239    pub fn on_leadership_lost(&mut self) {
240        self.state = State::NotLeader;
241    }
242
243    pub fn is_leader(&self) -> bool {
244        matches!(self.state, State::Leader { .. })
245    }
246
247    pub fn epoch(&self) -> Option<Epoch> {
248        match self.state {
249            State::Leader { epoch, .. } => Some(epoch),
250            State::NotLeader => None,
251        }
252    }
253
254    /// Single source of truth for the window-advance simulation and its bounds
255    /// checks, shared by `try_grant` and `would_grant`. Pure: it takes the
256    /// relevant state fields by value and mutates nothing, so a failed
257    /// projection cannot leave allocator state advanced.
258    ///
259    /// On success returns the `(physical_ms, logical_start)` the grant would
260    /// occupy. The two failure variants are kept distinct so `try_grant` can
261    /// surface the precise error its callers (and tests) expect;
262    /// `would_grant` collapses both to `false` via `.is_ok()`.
263    fn project_grant(
264        next_physical_ms: u64,
265        next_logical: u32,
266        committed_high_water: u64,
267        now_ms: u64,
268        count: u32,
269    ) -> Result<(u64, u32), CoreError> {
270        let mut physical_ms = next_physical_ms;
271        let mut logical = next_logical;
272
273        // Advance physical_ms toward wall clock if ahead. next_physical_ms is
274        // already at or above fence_floor, so a low now_ms simply leaves it there.
275        if now_ms > physical_ms {
276            physical_ms = now_ms;
277            logical = 0;
278        }
279
280        // If the current physical_ms cannot fit the request in its remaining
281        // logical range, advance to the next physical_ms.
282        if logical as u64 + count as u64 > LOGICAL_MAX as u64 + 1 {
283            physical_ms += 1;
284            logical = 0;
285        }
286
287        if physical_ms > PHYSICAL_MS_MAX {
288            return Err(CoreError::PhysicalMsOutOfRange(physical_ms));
289        }
290
291        // The fence: never issue a timestamp at a physical_ms above the committed
292        // high-water. If we are at or past the bound, the caller must extend.
293        if physical_ms > committed_high_water {
294            return Err(CoreError::WindowExhausted);
295        }
296
297        Ok((physical_ms, logical))
298    }
299
300    /// Hot path. Issue `count` timestamps from the in-memory window.
301    ///
302    /// Returns `WindowExhausted` when the in-memory remainder cannot cover the request;
303    /// the caller (typically the server) then runs prepare → persist → commit and retries.
304    ///
305    /// State is written back only on success: a failed grant (out-of-range or
306    /// exhausted window) leaves `next_physical_ms`/`next_logical` untouched.
307    pub fn try_grant(&mut self, now_ms: u64, count: u32) -> Result<WindowGrant, CoreError> {
308        if count == 0 {
309            return Err(CoreError::InvalidCount(0));
310        }
311        if count > LOGICAL_MAX + 1 {
312            return Err(CoreError::InvalidCount(count));
313        }
314        let State::Leader {
315            epoch,
316            committed_high_water,
317            next_physical_ms,
318            next_logical,
319        } = &mut self.state
320        else {
321            return Err(CoreError::NotLeader);
322        };
323
324        let (physical_ms, logical_start) = Self::project_grant(
325            *next_physical_ms,
326            *next_logical,
327            *committed_high_water,
328            now_ms,
329            count,
330        )?;
331
332        // project_grant already guarantees physical_ms and the logical range
333        // are in bounds, so this construction never fails in practice — but
334        // routing through the checked constructor propagates a CoreError rather
335        // than panicking should that invariant ever be violated, and keeps this
336        // path free of the unwrap/expect the crate's panic policy bans.
337        let grant = WindowGrant::try_new(physical_ms, logical_start, count, *epoch)?;
338        *next_physical_ms = physical_ms;
339        *next_logical = logical_start + count;
340        Ok(grant)
341    }
342
343    /// Non-mutating predicate: would `try_grant(now_ms, count)` succeed right
344    /// now? Used by the server's extension single-flight to decide whether a
345    /// peer extender has already added enough room, avoiding a redundant
346    /// `persist_high_water` round-trip. Delegates to the same `project_grant`
347    /// helper `try_grant` uses, so the exhaustion check cannot drift — a
348    /// coarser predicate would risk false positives (skip the extension, then
349    /// fail the outer retry) for requests whose `count` straddles the window edge.
350    pub fn would_grant(&self, now_ms: u64, count: u32) -> bool {
351        if count == 0 || count > LOGICAL_MAX + 1 {
352            return false;
353        }
354        let State::Leader {
355            committed_high_water,
356            next_physical_ms,
357            next_logical,
358            ..
359        } = &self.state
360        else {
361            return false;
362        };
363
364        Self::project_grant(
365            *next_physical_ms,
366            *next_logical,
367            *committed_high_water,
368            now_ms,
369            count,
370        )
371        .is_ok()
372    }
373
374    /// Compute the high-water value the caller should durably persist before
375    /// calling `try_commit_window_extension`. Does not mutate.
376    ///
377    /// Returns `max(committed_high_water + 1, now_ms) + ahead_ms`. The +1 on
378    /// `committed_high_water` guarantees forward progress when wall clock is
379    /// behind the persisted bound (rare, but possible after a clock-step-back).
380    ///
381    /// Returns `Err(CoreError::NotLeader)` off-leader, matching every other
382    /// mutating method. A `0` sentinel here would be indistinguishable from a
383    /// legitimately prepared bound, letting a caller that skipped `is_leader()`
384    /// proceed as if preparation had succeeded.
385    pub fn try_prepare_window_extension(
386        &self,
387        now_ms: u64,
388        ahead_ms: u64,
389    ) -> Result<u64, CoreError> {
390        match &self.state {
391            State::NotLeader => Err(CoreError::NotLeader),
392            State::Leader {
393                committed_high_water,
394                ..
395            } => {
396                let floor = committed_high_water
397                    .checked_add(1)
398                    .ok_or(CoreError::PhysicalMsOutOfRange(*committed_high_water))?;
399                let requested = core::cmp::max(floor, now_ms).checked_add(ahead_ms).ok_or(
400                    CoreError::WindowExtensionOverflow {
401                        floor,
402                        now_ms,
403                        ahead_ms,
404                    },
405                )?;
406                if requested > PHYSICAL_MS_MAX {
407                    return Err(CoreError::PhysicalMsOutOfRange(requested));
408                }
409                Ok(requested)
410            }
411        }
412    }
413
414    /// Apply a durably-persisted window extension. `persisted_high_water` is
415    /// the value returned by `ConsensusDriver::persist_high_water`, which is
416    /// monotonic — it may equal or exceed the value passed to prepare.
417    ///
418    /// The `expected_epoch` argument fences out late-arriving commits from a
419    /// prior leader epoch: if the allocator is no longer at this epoch (either
420    /// it has lost leadership or a new leader took over), the commit is
421    /// dropped. Combined with the server's drain barrier, this guarantees a
422    /// late persist from epoch N cannot raise the durable bound observed by
423    /// epoch N+M.
424    ///
425    /// Returns [`CommitOutcome`]: `Applied` when the bound advanced, or
426    /// `Ignored` (with the reason) for the three benign drop cases. A value
427    /// exceeding the 46-bit physical ceiling is an invariant violation, not a
428    /// benign drop, so it stays `Err(PhysicalMsOutOfRange)`.
429    pub fn try_commit_window_extension(
430        &mut self,
431        persisted_high_water: u64,
432        expected_epoch: Epoch,
433    ) -> Result<CommitOutcome, CoreError> {
434        if persisted_high_water > PHYSICAL_MS_MAX {
435            return Err(CoreError::PhysicalMsOutOfRange(persisted_high_water));
436        }
437        let State::Leader {
438            epoch,
439            committed_high_water,
440            ..
441        } = &mut self.state
442        else {
443            return Ok(CommitOutcome::Ignored(IgnoreReason::NotLeader));
444        };
445        // Epoch fencing takes precedence over the monotonic check: a late
446        // persist from a superseded epoch must report EpochMismatch even when
447        // its value also fails to advance, so churn is not masked as reordering.
448        if *epoch != expected_epoch {
449            return Ok(CommitOutcome::Ignored(IgnoreReason::EpochMismatch {
450                expected: expected_epoch,
451                current: *epoch,
452            }));
453        }
454        if persisted_high_water <= *committed_high_water {
455            return Ok(CommitOutcome::Ignored(IgnoreReason::NotAdvanced {
456                persisted: persisted_high_water,
457                committed: *committed_high_water,
458            }));
459        }
460        *committed_high_water = persisted_high_water;
461        Ok(CommitOutcome::Applied(persisted_high_water))
462    }
463}
464
465impl Default for Allocator {
466    fn default() -> Self {
467        Self::new()
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn new_allocator_is_not_leader() {
477        let allocator = Allocator::new();
478        assert!(!allocator.is_leader());
479        assert_eq!(allocator.epoch(), None);
480    }
481
482    #[test]
483    fn on_leadership_gained_sets_epoch() {
484        let mut allocator = Allocator::new();
485        allocator
486            .try_on_leadership_gained(1000, 5000, Epoch(5))
487            .unwrap();
488        assert!(allocator.is_leader());
489        assert_eq!(allocator.epoch(), Some(Epoch(5)));
490    }
491
492    #[test]
493    fn try_on_leadership_gained_rejects_out_of_range_window() {
494        let mut allocator = Allocator::new();
495        assert_eq!(
496            allocator.try_on_leadership_gained(PHYSICAL_MS_MAX + 1, PHYSICAL_MS_MAX + 1, Epoch(5)),
497            Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
498        );
499        // fence_floor in-range, ceiling out-of-range — separate guard.
500        assert_eq!(
501            allocator.try_on_leadership_gained(1_000, PHYSICAL_MS_MAX + 1, Epoch(5)),
502            Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
503        );
504        assert_eq!(
505            allocator.try_on_leadership_gained(5000, 4000, Epoch(5)),
506            Err(CoreError::InvalidLeadershipWindow {
507                fence_floor: 5000,
508                committed_ceiling: 4000
509            })
510        );
511    }
512
513    #[test]
514    fn on_leadership_lost_clears_state() {
515        let mut allocator = Allocator::new();
516        allocator
517            .try_on_leadership_gained(1000, 5000, Epoch(5))
518            .unwrap();
519        allocator.on_leadership_lost();
520        assert!(!allocator.is_leader());
521        assert_eq!(allocator.epoch(), None);
522    }
523
524    #[test]
525    fn try_grant_not_leader() {
526        let mut allocator = Allocator::new();
527        assert_eq!(allocator.try_grant(1000, 1), Err(CoreError::NotLeader));
528    }
529
530    #[test]
531    fn try_grant_zero_count() {
532        let mut allocator = Allocator::new();
533        allocator
534            .try_on_leadership_gained(1000, 5000, Epoch(1))
535            .unwrap();
536        assert_eq!(
537            allocator.try_grant(1000, 0),
538            Err(CoreError::InvalidCount(0))
539        );
540    }
541
542    #[test]
543    fn try_grant_oversized_count() {
544        let mut allocator = Allocator::new();
545        allocator
546            .try_on_leadership_gained(1000, 5000, Epoch(1))
547            .unwrap();
548        let oversized = LOGICAL_MAX + 2;
549        assert_eq!(
550            allocator.try_grant(1000, oversized),
551            Err(CoreError::InvalidCount(oversized))
552        );
553    }
554
555    #[test]
556    fn try_grant_above_committed_is_window_exhausted() {
557        // Advancing `now_ms` past `committed_high_water` correctly returns
558        // WindowExhausted; the server then extends.
559        let mut allocator = Allocator::new();
560        // fence_floor=5_000, ceiling=5_000 (tight window, no pre-extended gap).
561        allocator
562            .try_on_leadership_gained(5_000, 5_000, Epoch(1))
563            .unwrap();
564        // now_ms below floor: clamps to floor=5_000, which equals the ceiling → succeeds.
565        allocator.try_grant(4_999, 1).unwrap();
566        // now_ms above ceiling: window exhausted.
567        assert_eq!(
568            allocator.try_grant(5_001, 1),
569            Err(CoreError::WindowExhausted)
570        );
571    }
572
573    #[test]
574    fn failed_try_grant_does_not_advance_state() {
575        // A grant that fails the exhaustion check must leave the allocator's
576        // advance state untouched, so a later grant at a lower `now_ms` is not
577        // pinned to the failed attempt's wall clock.
578        let mut allocator = Allocator::new();
579        // Tight initial window: fence_floor == ceiling == 1_000.
580        allocator
581            .try_on_leadership_gained(1_000, 1_000, Epoch(1))
582            .unwrap();
583        // now_ms far past the ceiling exhausts the window.
584        assert_eq!(
585            allocator.try_grant(5_000, 1),
586            Err(CoreError::WindowExhausted)
587        );
588        // Extend the durable bound to exactly 2_000.
589        let target = allocator.try_prepare_window_extension(2_000, 0).unwrap();
590        assert_eq!(target, 2_000); // max(committed+1=1_001, now=2_000) + 0
591        allocator
592            .try_commit_window_extension(target, Epoch(1))
593            .unwrap();
594        // The failed grant must not have pinned next_physical_ms at 5_000: a
595        // grant at now_ms=2_000 advances cleanly to physical_ms=2_000 (<= the
596        // committed 2_000). If state had advanced on the failure, next_physical_ms
597        // would still be 5_000 and this would exhaust the window again.
598        let grant = allocator.try_grant(2_000, 1).unwrap();
599        assert_eq!(grant.physical_ms, 2_000);
600        assert_eq!(grant.logical_start, 0);
601    }
602
603    #[test]
604    fn try_grant_after_gain_serves_immediately() {
605        // The fence has already persisted a pre-extended window, so the allocator
606        // can serve immediately. Grants start at fence_floor regardless of now_ms.
607        let mut allocator = Allocator::new();
608        allocator
609            .try_on_leadership_gained(5_000, 10_000, Epoch(1))
610            .unwrap();
611        let grant = allocator.try_grant(1_000, 1).unwrap();
612        // now_ms=1_000 < fence_floor=5_000, so next_physical_ms stays at 5_000.
613        assert_eq!(grant.physical_ms, 5_000);
614        assert_eq!(grant.logical_start, 0);
615        assert_eq!(grant.epoch, Epoch(1));
616    }
617
618    #[test]
619    fn prepare_window_extension_not_leader() {
620        // Off-leader prepare must error like every other mutating method, not
621        // return a `0` that a caller could mistake for a prepared bound.
622        let allocator = Allocator::new();
623        assert_eq!(
624            allocator.try_prepare_window_extension(1000, 3000),
625            Err(CoreError::NotLeader)
626        );
627    }
628
629    #[test]
630    fn prepare_window_extension_uses_now_ms_when_ahead_of_high_water() {
631        let mut allocator = Allocator::new();
632        allocator
633            .try_on_leadership_gained(1000, 1000, Epoch(1))
634            .unwrap();
635        let target = allocator.try_prepare_window_extension(2000, 3000).unwrap();
636        assert_eq!(target, 5000); // max(1001, 2000) + 3000
637    }
638
639    #[test]
640    fn prepare_window_extension_uses_high_water_floor_when_clock_behind() {
641        let mut allocator = Allocator::new();
642        allocator
643            .try_on_leadership_gained(10_000, 10_000, Epoch(1))
644            .unwrap();
645        let target = allocator.try_prepare_window_extension(500, 3000).unwrap();
646        // floor = 10_001, clock = 500. max = 10_001. + 3000 = 13_001.
647        assert_eq!(target, 13_001);
648    }
649
650    #[test]
651    fn prepare_window_extension_rejects_out_of_range_target() {
652        let mut allocator = Allocator::new();
653        allocator
654            .try_on_leadership_gained(PHYSICAL_MS_MAX, PHYSICAL_MS_MAX, Epoch(1))
655            .unwrap();
656        assert_eq!(
657            allocator.try_prepare_window_extension(PHYSICAL_MS_MAX, 1),
658            Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 2))
659        );
660    }
661
662    #[test]
663    fn prepare_window_extension_overflow_names_all_operands() {
664        // A saturated clock (SystemClock::now_ms saturates to u64::MAX) plus any
665        // non-zero ahead_ms overflows max(floor, now_ms) + ahead_ms. The error
666        // must name the three real operands so the log points at the clock, not
667        // a phantom "someone passed an absurd physical_ms".
668        let mut allocator = Allocator::new();
669        allocator
670            .try_on_leadership_gained(1_000, 1_000, Epoch(1))
671            .unwrap();
672        assert_eq!(
673            allocator.try_prepare_window_extension(u64::MAX, 1),
674            Err(CoreError::WindowExtensionOverflow {
675                floor: 1_001,
676                now_ms: u64::MAX,
677                ahead_ms: 1,
678            })
679        );
680    }
681
682    #[test]
683    fn commit_then_try_grant_succeeds() {
684        let mut allocator = Allocator::new();
685        allocator
686            .try_on_leadership_gained(1000, 1000, Epoch(7))
687            .unwrap();
688        let target = allocator.try_prepare_window_extension(1000, 3000).unwrap();
689        assert_eq!(
690            allocator.try_commit_window_extension(target, Epoch(7)),
691            Ok(CommitOutcome::Applied(target))
692        );
693        let grant = allocator.try_grant(1000, 5).unwrap();
694        assert_eq!(grant.count, 5);
695        assert_eq!(grant.logical_start, 0);
696        assert_eq!(grant.epoch, Epoch(7));
697        // physical_ms should be at most the persisted high-water.
698        assert!(grant.physical_ms <= target);
699    }
700
701    #[test]
702    fn commit_with_lower_value_is_ignored() {
703        let mut allocator = Allocator::new();
704        allocator
705            .try_on_leadership_gained(1000, 1000, Epoch(1))
706            .unwrap();
707        assert_eq!(
708            allocator.try_commit_window_extension(5000, Epoch(1)),
709            Ok(CommitOutcome::Applied(5000))
710        );
711        // A non-advancing commit reports the values so the caller can tell a
712        // monotonic-bound regression (persist reordering) from epoch churn.
713        assert_eq!(
714            allocator.try_commit_window_extension(3000, Epoch(1)),
715            Ok(CommitOutcome::Ignored(IgnoreReason::NotAdvanced {
716                persisted: 3000,
717                committed: 5000,
718            }))
719        );
720        // try_grant up to physical_ms=5000 should still work.
721        let grant = allocator.try_grant(4500, 1).unwrap();
722        assert_eq!(grant.physical_ms, 4500);
723    }
724
725    #[test]
726    fn commit_with_equal_value_is_ignored_not_applied() {
727        // persist_high_water is monotonic and may *equal* the prepared bound; an
728        // equal value moves nothing, so it is Ignored(NotAdvanced), not Applied.
729        let mut allocator = Allocator::new();
730        allocator
731            .try_on_leadership_gained(1000, 5000, Epoch(1))
732            .unwrap();
733        assert_eq!(
734            allocator.try_commit_window_extension(5000, Epoch(1)),
735            Ok(CommitOutcome::Ignored(IgnoreReason::NotAdvanced {
736                persisted: 5000,
737                committed: 5000,
738            }))
739        );
740    }
741
742    #[test]
743    fn commit_rejects_out_of_range_high_water() {
744        let mut allocator = Allocator::new();
745        allocator
746            .try_on_leadership_gained(1000, 1000, Epoch(1))
747            .unwrap();
748        assert_eq!(
749            allocator.try_commit_window_extension(PHYSICAL_MS_MAX + 1, Epoch(1)),
750            Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
751        );
752    }
753
754    #[test]
755    fn try_grant_rejects_out_of_range_clock() {
756        let mut allocator = Allocator::new();
757        allocator
758            .try_on_leadership_gained(1000, PHYSICAL_MS_MAX, Epoch(1))
759            .unwrap();
760        assert_eq!(
761            allocator.try_grant(PHYSICAL_MS_MAX + 1, 1),
762            Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
763        );
764    }
765
766    #[test]
767    fn commit_at_wrong_epoch_is_silently_dropped() {
768        let mut allocator = Allocator::new();
769        // fence_floor=1000, ceiling=1000: tight initial window.
770        allocator
771            .try_on_leadership_gained(1000, 1000, Epoch(5))
772            .unwrap();
773        // A late persist from epoch 4 (the prior leader) — fenced out. The
774        // outcome names both epochs so the caller can metric epoch churn.
775        assert_eq!(
776            allocator.try_commit_window_extension(9_999, Epoch(4)),
777            Ok(CommitOutcome::Ignored(IgnoreReason::EpochMismatch {
778                expected: Epoch(4),
779                current: Epoch(5),
780            }))
781        );
782        // The allocator's bound did not move; a grant at now=900 clamps to
783        // floor=1000, and a request with now=1_100 exhausts the window.
784        allocator.try_grant(900, 1).unwrap();
785        assert_eq!(
786            allocator.try_grant(1_100, 1),
787            Err(CoreError::WindowExhausted)
788        );
789    }
790
791    #[test]
792    fn commit_after_leadership_lost_is_ignored() {
793        let mut allocator = Allocator::new();
794        allocator
795            .try_on_leadership_gained(1000, 5000, Epoch(1))
796            .unwrap();
797        allocator.on_leadership_lost();
798        assert_eq!(
799            allocator.try_commit_window_extension(9_999, Epoch(1)),
800            Ok(CommitOutcome::Ignored(IgnoreReason::NotLeader))
801        );
802        assert!(!allocator.is_leader());
803    }
804
805    #[test]
806    fn would_grant_matches_try_grant_outcome() {
807        let mut allocator = Allocator::new();
808        // Not leader: never grants.
809        assert!(!allocator.would_grant(1_000, 1));
810        // Invalid counts: never grants.
811        allocator
812            .try_on_leadership_gained(1_000, 5_000, Epoch(1))
813            .unwrap();
814        assert!(!allocator.would_grant(1_000, 0));
815        assert!(!allocator.would_grant(1_000, LOGICAL_MAX + 2));
816        // Within-window: matches try_grant. now_ms below floor still grants
817        // (clamped to floor=1_000, ceiling=5_000).
818        assert!(allocator.would_grant(0, 1));
819        // now_ms above ceiling: predicate refuses (would exhaust).
820        assert!(!allocator.would_grant(5_001, 1));
821        // Mid-window now_ms advances the predicate's internal physical_ms.
822        assert!(allocator.would_grant(2_500, 1));
823    }
824
825    #[test]
826    fn would_grant_predicts_logical_wrap_advance() {
827        // When (logical + count) overflows the per-ms logical range, the
828        // predicate (like try_grant) advances physical_ms by 1. If that
829        // advance leaves the window, would_grant must return false.
830        let mut allocator = Allocator::new();
831        allocator
832            .try_on_leadership_gained(1_000, 1_000, Epoch(1))
833            .unwrap();
834        // count >= LOGICAL_MAX + 1 forces the advance branch on a fresh
835        // window: logical(0) + count(LOGICAL_MAX+1) doesn't overflow on its
836        // own, but anything one bigger does. Use LOGICAL_MAX + 1 to land at
837        // the edge, then any non-zero issue advances physical_ms.
838        allocator.try_grant(1_000, LOGICAL_MAX + 1).unwrap();
839        // Next grant of size 1 would advance to physical_ms = 1_001, which
840        // exceeds the committed ceiling of 1_000.
841        assert!(!allocator.would_grant(1_000, 1));
842    }
843
844    #[test]
845    fn would_grant_returns_false_when_advance_exceeds_physical_max() {
846        // Construct an allocator at PHYSICAL_MS_MAX so the +1 advance
847        // crosses the 46-bit ceiling and the predicate refuses.
848        let mut allocator = Allocator::new();
849        allocator
850            .try_on_leadership_gained(PHYSICAL_MS_MAX, PHYSICAL_MS_MAX, Epoch(1))
851            .unwrap();
852        // Fill the logical range so the next would_grant call has to
853        // advance physical_ms.
854        allocator
855            .try_grant(PHYSICAL_MS_MAX, LOGICAL_MAX + 1)
856            .unwrap();
857        assert!(!allocator.would_grant(PHYSICAL_MS_MAX, 1));
858    }
859
860    #[test]
861    fn default_constructs_not_leader_allocator() {
862        let allocator = Allocator::default();
863        assert!(!allocator.is_leader());
864        assert_eq!(allocator.epoch(), None);
865    }
866
867    #[test]
868    fn logical_wraps_to_next_physical_ms() {
869        let mut allocator = Allocator::new();
870        // fence_floor=0, ceiling=0; extend to 10 before granting.
871        allocator.try_on_leadership_gained(0, 0, Epoch(1)).unwrap();
872        allocator.try_commit_window_extension(10, Epoch(1)).unwrap();
873        // Issue LOGICAL_MAX+1 logicals at physical_ms=1, then one more should bump to 2.
874        let first = allocator.try_grant(1, LOGICAL_MAX + 1).unwrap();
875        assert_eq!(first.physical_ms, 1);
876        assert_eq!(first.logical_start, 0);
877        let second = allocator.try_grant(1, 1).unwrap();
878        assert_eq!(second.physical_ms, 2);
879        assert_eq!(second.logical_start, 0);
880    }
881
882    #[test]
883    fn try_new_accepts_valid_grant_and_packs_boundaries() {
884        // A checked grant exposes its fields through accessors and its
885        // boundary timestamps pack without panicking — first() at
886        // logical_start, last() at logical_start + count - 1.
887        let grant = WindowGrant::try_new(1_000, 5, 3, Epoch(7)).unwrap();
888        assert_eq!(grant.physical_ms(), 1_000);
889        assert_eq!(grant.logical_start(), 5);
890        assert_eq!(grant.count(), 3);
891        assert_eq!(grant.epoch(), Epoch(7));
892        assert_eq!(grant.first(), Timestamp::pack(1_000, 5));
893        assert_eq!(grant.last(), Timestamp::pack(1_000, 7));
894    }
895
896    #[test]
897    fn try_new_accepts_max_logical_boundary() {
898        // logical_start + count - 1 == LOGICAL_MAX is the widest in-range grant.
899        let grant = WindowGrant::try_new(1_000, 0, LOGICAL_MAX + 1, Epoch(1)).unwrap();
900        assert_eq!(grant.last(), Timestamp::pack(1_000, LOGICAL_MAX));
901    }
902
903    #[test]
904    fn try_new_rejects_zero_count() {
905        // count == 0 would underflow logical_start + count - 1 in last().
906        assert_eq!(
907            WindowGrant::try_new(1_000, 0, 0, Epoch(1)),
908            Err(CoreError::InvalidCount(0))
909        );
910    }
911
912    #[test]
913    fn try_new_rejects_out_of_range_physical_ms() {
914        assert_eq!(
915            WindowGrant::try_new(PHYSICAL_MS_MAX + 1, 0, 1, Epoch(1)),
916            Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
917        );
918    }
919
920    #[test]
921    fn try_new_rejects_logical_range_overflow() {
922        // last logical (logical_start + count - 1) exceeds the 18-bit field.
923        assert_eq!(
924            WindowGrant::try_new(1_000, LOGICAL_MAX, 2, Epoch(1)),
925            Err(CoreError::LogicalRangeOutOfRange {
926                logical_start: LOGICAL_MAX,
927                count: 2
928            })
929        );
930    }
931
932    #[test]
933    fn try_new_rejects_logical_count_u32_overflow() {
934        // logical_start + (count - 1) overflows u32 before any range check.
935        assert_eq!(
936            WindowGrant::try_new(1_000, u32::MAX, 2, Epoch(1)),
937            Err(CoreError::LogicalRangeOutOfRange {
938                logical_start: u32::MAX,
939                count: 2
940            })
941        );
942    }
943}