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 /// Shared count guard for `try_grant` and `would_grant`, kept here so the
255 /// two entry points cannot drift. The server's extension single-flight
256 /// relies on `would_grant(now_ms, count) == true` implying the retry
257 /// `try_grant(now_ms, count)` succeeds (see `service::extend_window`), so
258 /// the set of rejected counts must be identical on both paths — splitting
259 /// this check across both methods would let a future edit to one degrade
260 /// the recheck into a spurious consensus round-trip (or worse).
261 ///
262 /// `count == 0` reuses the oversized case's `InvalidCount(count)`; since
263 /// `count` is `0` there, the surfaced value matches the prior explicit
264 /// `InvalidCount(0)`.
265 fn validate_count(count: u32) -> Result<(), CoreError> {
266 if count == 0 || count > LOGICAL_MAX + 1 {
267 return Err(CoreError::InvalidCount(count));
268 }
269 Ok(())
270 }
271
272 /// Single source of truth for the window-advance simulation and its bounds
273 /// checks, shared by `try_grant` and `would_grant`. Pure: it takes the
274 /// relevant state fields by value and mutates nothing, so a failed
275 /// projection cannot leave allocator state advanced.
276 ///
277 /// On success returns the `(physical_ms, logical_start)` the grant would
278 /// occupy. The two failure variants are kept distinct so `try_grant` can
279 /// surface the precise error its callers (and tests) expect;
280 /// `would_grant` collapses both to `false` via `.is_ok()`.
281 fn project_grant(
282 next_physical_ms: u64,
283 next_logical: u32,
284 committed_high_water: u64,
285 now_ms: u64,
286 count: u32,
287 ) -> Result<(u64, u32), CoreError> {
288 let mut physical_ms = next_physical_ms;
289 let mut logical = next_logical;
290
291 // Advance physical_ms toward wall clock if ahead. next_physical_ms is
292 // already at or above fence_floor, so a low now_ms simply leaves it there.
293 if now_ms > physical_ms {
294 physical_ms = now_ms;
295 logical = 0;
296 }
297
298 // If the current physical_ms cannot fit the request in its remaining
299 // logical range, advance to the next physical_ms.
300 if logical as u64 + count as u64 > LOGICAL_MAX as u64 + 1 {
301 physical_ms += 1;
302 logical = 0;
303 }
304
305 if physical_ms > PHYSICAL_MS_MAX {
306 return Err(CoreError::PhysicalMsOutOfRange(physical_ms));
307 }
308
309 // The fence: never issue a timestamp at a physical_ms above the committed
310 // high-water. If we are at or past the bound, the caller must extend.
311 if physical_ms > committed_high_water {
312 return Err(CoreError::WindowExhausted);
313 }
314
315 Ok((physical_ms, logical))
316 }
317
318 /// Hot path. Issue `count` timestamps from the in-memory window.
319 ///
320 /// Returns `WindowExhausted` when the in-memory remainder cannot cover the request;
321 /// the caller (typically the server) then runs prepare → persist → commit and retries.
322 ///
323 /// State is written back only on success: a failed grant (out-of-range or
324 /// exhausted window) leaves `next_physical_ms`/`next_logical` untouched.
325 pub fn try_grant(&mut self, now_ms: u64, count: u32) -> Result<WindowGrant, CoreError> {
326 Self::validate_count(count)?;
327 let State::Leader {
328 epoch,
329 committed_high_water,
330 next_physical_ms,
331 next_logical,
332 } = &mut self.state
333 else {
334 return Err(CoreError::NotLeader);
335 };
336
337 let (physical_ms, logical_start) = Self::project_grant(
338 *next_physical_ms,
339 *next_logical,
340 *committed_high_water,
341 now_ms,
342 count,
343 )?;
344
345 // project_grant already guarantees physical_ms and the logical range
346 // are in bounds, so this construction never fails in practice — but
347 // routing through the checked constructor propagates a CoreError rather
348 // than panicking should that invariant ever be violated, and keeps this
349 // path free of the unwrap/expect the crate's panic policy bans.
350 let grant = WindowGrant::try_new(physical_ms, logical_start, count, *epoch)?;
351 // Normalize the write-back so the stored cursor is always a packable
352 // (physical_ms, logical) pair. `project_grant` admits a grant that fills
353 // the millisecond exactly, so `logical_start + count` can reach
354 // LOGICAL_MAX + 1 — a logical the packed layout cannot hold. Rather than
355 // store that sentinel and rely on the next `project_grant` call to wrap
356 // it, roll to the next millisecond at logical 0 here. This is precisely
357 // the position the next call would compute, so behavior is unchanged;
358 // the stored state just no longer depends on the implicit
359 // "next call always wraps or resets" invariant. (`next_physical_ms` may
360 // become PHYSICAL_MS_MAX + 1, which is never packed directly and is
361 // rejected as out-of-range by the next grant's `project_grant`.)
362 let next_logical_after = logical_start + count;
363 if next_logical_after > LOGICAL_MAX {
364 *next_physical_ms = physical_ms + 1;
365 *next_logical = 0;
366 } else {
367 *next_physical_ms = physical_ms;
368 *next_logical = next_logical_after;
369 }
370 Ok(grant)
371 }
372
373 /// Non-mutating predicate: would `try_grant(now_ms, count)` succeed right
374 /// now? Used by the server's extension single-flight to decide whether a
375 /// peer extender has already added enough room, avoiding a redundant
376 /// `persist_high_water` round-trip. Delegates to the same `project_grant`
377 /// helper `try_grant` uses, so the exhaustion check cannot drift — a
378 /// coarser predicate would risk false positives (skip the extension, then
379 /// fail the outer retry) for requests whose `count` straddles the window edge.
380 pub fn would_grant(&self, now_ms: u64, count: u32) -> bool {
381 if Self::validate_count(count).is_err() {
382 return false;
383 }
384 let State::Leader {
385 committed_high_water,
386 next_physical_ms,
387 next_logical,
388 ..
389 } = &self.state
390 else {
391 return false;
392 };
393
394 Self::project_grant(
395 *next_physical_ms,
396 *next_logical,
397 *committed_high_water,
398 now_ms,
399 count,
400 )
401 .is_ok()
402 }
403
404 /// Compute the high-water value the caller should durably persist before
405 /// calling `try_commit_window_extension`. Does not mutate.
406 ///
407 /// Returns `max(committed_high_water + 1, now_ms) + ahead_ms`. The +1 on
408 /// `committed_high_water` guarantees forward progress when wall clock is
409 /// behind the persisted bound (rare, but possible after a clock-step-back).
410 ///
411 /// Returns `Err(CoreError::NotLeader)` off-leader, matching every other
412 /// mutating method. A `0` sentinel here would be indistinguishable from a
413 /// legitimately prepared bound, letting a caller that skipped `is_leader()`
414 /// proceed as if preparation had succeeded.
415 pub fn try_prepare_window_extension(
416 &self,
417 now_ms: u64,
418 ahead_ms: u64,
419 ) -> Result<u64, CoreError> {
420 match &self.state {
421 State::NotLeader => Err(CoreError::NotLeader),
422 State::Leader {
423 committed_high_water,
424 ..
425 } => {
426 let floor = committed_high_water
427 .checked_add(1)
428 .ok_or(CoreError::PhysicalMsOutOfRange(*committed_high_water))?;
429 let requested = core::cmp::max(floor, now_ms).checked_add(ahead_ms).ok_or(
430 CoreError::WindowExtensionOverflow {
431 floor,
432 now_ms,
433 ahead_ms,
434 },
435 )?;
436 if requested > PHYSICAL_MS_MAX {
437 return Err(CoreError::PhysicalMsOutOfRange(requested));
438 }
439 Ok(requested)
440 }
441 }
442 }
443
444 /// Apply a durably-persisted window extension. `persisted_high_water` is
445 /// the value returned by `ConsensusDriver::persist_high_water`, which is
446 /// monotonic — it may equal or exceed the value passed to prepare.
447 ///
448 /// The `expected_epoch` argument fences out late-arriving commits from a
449 /// prior leader epoch: if the allocator is no longer at this epoch (either
450 /// it has lost leadership or a new leader took over), the commit is
451 /// dropped. Combined with the server's drain barrier, this guarantees a
452 /// late persist from epoch N cannot raise the durable bound observed by
453 /// epoch N+M.
454 ///
455 /// Returns [`CommitOutcome`]: `Applied` when the bound advanced, or
456 /// `Ignored` (with the reason) for the three benign drop cases. A value
457 /// exceeding the 46-bit physical ceiling is an invariant violation, not a
458 /// benign drop, so it stays `Err(PhysicalMsOutOfRange)`.
459 pub fn try_commit_window_extension(
460 &mut self,
461 persisted_high_water: u64,
462 expected_epoch: Epoch,
463 ) -> Result<CommitOutcome, CoreError> {
464 if persisted_high_water > PHYSICAL_MS_MAX {
465 return Err(CoreError::PhysicalMsOutOfRange(persisted_high_water));
466 }
467 let State::Leader {
468 epoch,
469 committed_high_water,
470 ..
471 } = &mut self.state
472 else {
473 return Ok(CommitOutcome::Ignored(IgnoreReason::NotLeader));
474 };
475 // Epoch fencing takes precedence over the monotonic check: a late
476 // persist from a superseded epoch must report EpochMismatch even when
477 // its value also fails to advance, so churn is not masked as reordering.
478 if *epoch != expected_epoch {
479 return Ok(CommitOutcome::Ignored(IgnoreReason::EpochMismatch {
480 expected: expected_epoch,
481 current: *epoch,
482 }));
483 }
484 if persisted_high_water <= *committed_high_water {
485 return Ok(CommitOutcome::Ignored(IgnoreReason::NotAdvanced {
486 persisted: persisted_high_water,
487 committed: *committed_high_water,
488 }));
489 }
490 *committed_high_water = persisted_high_water;
491 Ok(CommitOutcome::Applied(persisted_high_water))
492 }
493}
494
495impl Default for Allocator {
496 fn default() -> Self {
497 Self::new()
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn new_allocator_is_not_leader() {
507 let allocator = Allocator::new();
508 assert!(!allocator.is_leader());
509 assert_eq!(allocator.epoch(), None);
510 }
511
512 #[test]
513 fn on_leadership_gained_sets_epoch() {
514 let mut allocator = Allocator::new();
515 allocator
516 .try_on_leadership_gained(1000, 5000, Epoch(5))
517 .unwrap();
518 assert!(allocator.is_leader());
519 assert_eq!(allocator.epoch(), Some(Epoch(5)));
520 }
521
522 #[test]
523 fn try_on_leadership_gained_rejects_out_of_range_window() {
524 let mut allocator = Allocator::new();
525 assert_eq!(
526 allocator.try_on_leadership_gained(PHYSICAL_MS_MAX + 1, PHYSICAL_MS_MAX + 1, Epoch(5)),
527 Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
528 );
529 // fence_floor in-range, ceiling out-of-range — separate guard.
530 assert_eq!(
531 allocator.try_on_leadership_gained(1_000, PHYSICAL_MS_MAX + 1, Epoch(5)),
532 Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
533 );
534 assert_eq!(
535 allocator.try_on_leadership_gained(5000, 4000, Epoch(5)),
536 Err(CoreError::InvalidLeadershipWindow {
537 fence_floor: 5000,
538 committed_ceiling: 4000
539 })
540 );
541 }
542
543 #[test]
544 fn on_leadership_lost_clears_state() {
545 let mut allocator = Allocator::new();
546 allocator
547 .try_on_leadership_gained(1000, 5000, Epoch(5))
548 .unwrap();
549 allocator.on_leadership_lost();
550 assert!(!allocator.is_leader());
551 assert_eq!(allocator.epoch(), None);
552 }
553
554 #[test]
555 fn try_grant_not_leader() {
556 let mut allocator = Allocator::new();
557 assert_eq!(allocator.try_grant(1000, 1), Err(CoreError::NotLeader));
558 }
559
560 #[test]
561 fn try_grant_zero_count() {
562 let mut allocator = Allocator::new();
563 allocator
564 .try_on_leadership_gained(1000, 5000, Epoch(1))
565 .unwrap();
566 assert_eq!(
567 allocator.try_grant(1000, 0),
568 Err(CoreError::InvalidCount(0))
569 );
570 }
571
572 #[test]
573 fn try_grant_oversized_count() {
574 let mut allocator = Allocator::new();
575 allocator
576 .try_on_leadership_gained(1000, 5000, Epoch(1))
577 .unwrap();
578 let oversized = LOGICAL_MAX + 2;
579 assert_eq!(
580 allocator.try_grant(1000, oversized),
581 Err(CoreError::InvalidCount(oversized))
582 );
583 }
584
585 #[test]
586 fn try_grant_above_committed_is_window_exhausted() {
587 // Advancing `now_ms` past `committed_high_water` correctly returns
588 // WindowExhausted; the server then extends.
589 let mut allocator = Allocator::new();
590 // fence_floor=5_000, ceiling=5_000 (tight window, no pre-extended gap).
591 allocator
592 .try_on_leadership_gained(5_000, 5_000, Epoch(1))
593 .unwrap();
594 // now_ms below floor: clamps to floor=5_000, which equals the ceiling → succeeds.
595 allocator.try_grant(4_999, 1).unwrap();
596 // now_ms above ceiling: window exhausted.
597 assert_eq!(
598 allocator.try_grant(5_001, 1),
599 Err(CoreError::WindowExhausted)
600 );
601 }
602
603 #[test]
604 fn failed_try_grant_does_not_advance_state() {
605 // A grant that fails the exhaustion check must leave the allocator's
606 // advance state untouched, so a later grant at a lower `now_ms` is not
607 // pinned to the failed attempt's wall clock.
608 let mut allocator = Allocator::new();
609 // Tight initial window: fence_floor == ceiling == 1_000.
610 allocator
611 .try_on_leadership_gained(1_000, 1_000, Epoch(1))
612 .unwrap();
613 // now_ms far past the ceiling exhausts the window.
614 assert_eq!(
615 allocator.try_grant(5_000, 1),
616 Err(CoreError::WindowExhausted)
617 );
618 // Extend the durable bound to exactly 2_000.
619 let target = allocator.try_prepare_window_extension(2_000, 0).unwrap();
620 assert_eq!(target, 2_000); // max(committed+1=1_001, now=2_000) + 0
621 allocator
622 .try_commit_window_extension(target, Epoch(1))
623 .unwrap();
624 // The failed grant must not have pinned next_physical_ms at 5_000: a
625 // grant at now_ms=2_000 advances cleanly to physical_ms=2_000 (<= the
626 // committed 2_000). If state had advanced on the failure, next_physical_ms
627 // would still be 5_000 and this would exhaust the window again.
628 let grant = allocator.try_grant(2_000, 1).unwrap();
629 assert_eq!(grant.physical_ms, 2_000);
630 assert_eq!(grant.logical_start, 0);
631 }
632
633 #[test]
634 fn try_grant_after_gain_serves_immediately() {
635 // The fence has already persisted a pre-extended window, so the allocator
636 // can serve immediately. Grants start at fence_floor regardless of now_ms.
637 let mut allocator = Allocator::new();
638 allocator
639 .try_on_leadership_gained(5_000, 10_000, Epoch(1))
640 .unwrap();
641 let grant = allocator.try_grant(1_000, 1).unwrap();
642 // now_ms=1_000 < fence_floor=5_000, so next_physical_ms stays at 5_000.
643 assert_eq!(grant.physical_ms, 5_000);
644 assert_eq!(grant.logical_start, 0);
645 assert_eq!(grant.epoch, Epoch(1));
646 }
647
648 #[test]
649 fn prepare_window_extension_not_leader() {
650 // Off-leader prepare must error like every other mutating method, not
651 // return a `0` that a caller could mistake for a prepared bound.
652 let allocator = Allocator::new();
653 assert_eq!(
654 allocator.try_prepare_window_extension(1000, 3000),
655 Err(CoreError::NotLeader)
656 );
657 }
658
659 #[test]
660 fn prepare_window_extension_uses_now_ms_when_ahead_of_high_water() {
661 let mut allocator = Allocator::new();
662 allocator
663 .try_on_leadership_gained(1000, 1000, Epoch(1))
664 .unwrap();
665 let target = allocator.try_prepare_window_extension(2000, 3000).unwrap();
666 assert_eq!(target, 5000); // max(1001, 2000) + 3000
667 }
668
669 #[test]
670 fn prepare_window_extension_uses_high_water_floor_when_clock_behind() {
671 let mut allocator = Allocator::new();
672 allocator
673 .try_on_leadership_gained(10_000, 10_000, Epoch(1))
674 .unwrap();
675 let target = allocator.try_prepare_window_extension(500, 3000).unwrap();
676 // floor = 10_001, clock = 500. max = 10_001. + 3000 = 13_001.
677 assert_eq!(target, 13_001);
678 }
679
680 #[test]
681 fn prepare_window_extension_rejects_out_of_range_target() {
682 let mut allocator = Allocator::new();
683 allocator
684 .try_on_leadership_gained(PHYSICAL_MS_MAX, PHYSICAL_MS_MAX, Epoch(1))
685 .unwrap();
686 assert_eq!(
687 allocator.try_prepare_window_extension(PHYSICAL_MS_MAX, 1),
688 Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 2))
689 );
690 }
691
692 #[test]
693 fn prepare_window_extension_overflow_names_all_operands() {
694 // A saturated clock (SystemClock::now_ms saturates to u64::MAX) plus any
695 // non-zero ahead_ms overflows max(floor, now_ms) + ahead_ms. The error
696 // must name the three real operands so the log points at the clock, not
697 // a phantom "someone passed an absurd physical_ms".
698 let mut allocator = Allocator::new();
699 allocator
700 .try_on_leadership_gained(1_000, 1_000, Epoch(1))
701 .unwrap();
702 assert_eq!(
703 allocator.try_prepare_window_extension(u64::MAX, 1),
704 Err(CoreError::WindowExtensionOverflow {
705 floor: 1_001,
706 now_ms: u64::MAX,
707 ahead_ms: 1,
708 })
709 );
710 }
711
712 #[test]
713 fn commit_then_try_grant_succeeds() {
714 let mut allocator = Allocator::new();
715 allocator
716 .try_on_leadership_gained(1000, 1000, Epoch(7))
717 .unwrap();
718 let target = allocator.try_prepare_window_extension(1000, 3000).unwrap();
719 assert_eq!(
720 allocator.try_commit_window_extension(target, Epoch(7)),
721 Ok(CommitOutcome::Applied(target))
722 );
723 let grant = allocator.try_grant(1000, 5).unwrap();
724 assert_eq!(grant.count, 5);
725 assert_eq!(grant.logical_start, 0);
726 assert_eq!(grant.epoch, Epoch(7));
727 // physical_ms should be at most the persisted high-water.
728 assert!(grant.physical_ms <= target);
729 }
730
731 #[test]
732 fn commit_with_lower_value_is_ignored() {
733 let mut allocator = Allocator::new();
734 allocator
735 .try_on_leadership_gained(1000, 1000, Epoch(1))
736 .unwrap();
737 assert_eq!(
738 allocator.try_commit_window_extension(5000, Epoch(1)),
739 Ok(CommitOutcome::Applied(5000))
740 );
741 // A non-advancing commit reports the values so the caller can tell a
742 // monotonic-bound regression (persist reordering) from epoch churn.
743 assert_eq!(
744 allocator.try_commit_window_extension(3000, Epoch(1)),
745 Ok(CommitOutcome::Ignored(IgnoreReason::NotAdvanced {
746 persisted: 3000,
747 committed: 5000,
748 }))
749 );
750 // try_grant up to physical_ms=5000 should still work.
751 let grant = allocator.try_grant(4500, 1).unwrap();
752 assert_eq!(grant.physical_ms, 4500);
753 }
754
755 #[test]
756 fn commit_with_equal_value_is_ignored_not_applied() {
757 // persist_high_water is monotonic and may *equal* the prepared bound; an
758 // equal value moves nothing, so it is Ignored(NotAdvanced), not Applied.
759 let mut allocator = Allocator::new();
760 allocator
761 .try_on_leadership_gained(1000, 5000, Epoch(1))
762 .unwrap();
763 assert_eq!(
764 allocator.try_commit_window_extension(5000, Epoch(1)),
765 Ok(CommitOutcome::Ignored(IgnoreReason::NotAdvanced {
766 persisted: 5000,
767 committed: 5000,
768 }))
769 );
770 }
771
772 #[test]
773 fn commit_rejects_out_of_range_high_water() {
774 let mut allocator = Allocator::new();
775 allocator
776 .try_on_leadership_gained(1000, 1000, Epoch(1))
777 .unwrap();
778 assert_eq!(
779 allocator.try_commit_window_extension(PHYSICAL_MS_MAX + 1, Epoch(1)),
780 Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
781 );
782 }
783
784 #[test]
785 fn try_grant_rejects_out_of_range_clock() {
786 let mut allocator = Allocator::new();
787 allocator
788 .try_on_leadership_gained(1000, PHYSICAL_MS_MAX, Epoch(1))
789 .unwrap();
790 assert_eq!(
791 allocator.try_grant(PHYSICAL_MS_MAX + 1, 1),
792 Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
793 );
794 }
795
796 #[test]
797 fn commit_at_wrong_epoch_is_silently_dropped() {
798 let mut allocator = Allocator::new();
799 // fence_floor=1000, ceiling=1000: tight initial window.
800 allocator
801 .try_on_leadership_gained(1000, 1000, Epoch(5))
802 .unwrap();
803 // A late persist from epoch 4 (the prior leader) — fenced out. The
804 // outcome names both epochs so the caller can metric epoch churn.
805 assert_eq!(
806 allocator.try_commit_window_extension(9_999, Epoch(4)),
807 Ok(CommitOutcome::Ignored(IgnoreReason::EpochMismatch {
808 expected: Epoch(4),
809 current: Epoch(5),
810 }))
811 );
812 // The allocator's bound did not move; a grant at now=900 clamps to
813 // floor=1000, and a request with now=1_100 exhausts the window.
814 allocator.try_grant(900, 1).unwrap();
815 assert_eq!(
816 allocator.try_grant(1_100, 1),
817 Err(CoreError::WindowExhausted)
818 );
819 }
820
821 #[test]
822 fn commit_after_leadership_lost_is_ignored() {
823 let mut allocator = Allocator::new();
824 allocator
825 .try_on_leadership_gained(1000, 5000, Epoch(1))
826 .unwrap();
827 allocator.on_leadership_lost();
828 assert_eq!(
829 allocator.try_commit_window_extension(9_999, Epoch(1)),
830 Ok(CommitOutcome::Ignored(IgnoreReason::NotLeader))
831 );
832 assert!(!allocator.is_leader());
833 }
834
835 #[test]
836 fn would_grant_matches_try_grant_outcome() {
837 let mut allocator = Allocator::new();
838 // Not leader: never grants.
839 assert!(!allocator.would_grant(1_000, 1));
840 // Invalid counts: never grants.
841 allocator
842 .try_on_leadership_gained(1_000, 5_000, Epoch(1))
843 .unwrap();
844 assert!(!allocator.would_grant(1_000, 0));
845 assert!(!allocator.would_grant(1_000, LOGICAL_MAX + 2));
846 // Within-window: matches try_grant. now_ms below floor still grants
847 // (clamped to floor=1_000, ceiling=5_000).
848 assert!(allocator.would_grant(0, 1));
849 // now_ms above ceiling: predicate refuses (would exhaust).
850 assert!(!allocator.would_grant(5_001, 1));
851 // Mid-window now_ms advances the predicate's internal physical_ms.
852 assert!(allocator.would_grant(2_500, 1));
853 }
854
855 #[test]
856 fn would_grant_predicts_logical_wrap_advance() {
857 // When (logical + count) overflows the per-ms logical range, the
858 // predicate (like try_grant) advances physical_ms by 1. If that
859 // advance leaves the window, would_grant must return false.
860 let mut allocator = Allocator::new();
861 allocator
862 .try_on_leadership_gained(1_000, 1_000, Epoch(1))
863 .unwrap();
864 // count >= LOGICAL_MAX + 1 forces the advance branch on a fresh
865 // window: logical(0) + count(LOGICAL_MAX+1) doesn't overflow on its
866 // own, but anything one bigger does. Use LOGICAL_MAX + 1 to land at
867 // the edge, then any non-zero issue advances physical_ms.
868 allocator.try_grant(1_000, LOGICAL_MAX + 1).unwrap();
869 // Next grant of size 1 would advance to physical_ms = 1_001, which
870 // exceeds the committed ceiling of 1_000.
871 assert!(!allocator.would_grant(1_000, 1));
872 }
873
874 #[test]
875 fn would_grant_returns_false_when_advance_exceeds_physical_max() {
876 // Construct an allocator at PHYSICAL_MS_MAX so the +1 advance
877 // crosses the 46-bit ceiling and the predicate refuses.
878 let mut allocator = Allocator::new();
879 allocator
880 .try_on_leadership_gained(PHYSICAL_MS_MAX, PHYSICAL_MS_MAX, Epoch(1))
881 .unwrap();
882 // Fill the logical range so the next would_grant call has to
883 // advance physical_ms.
884 allocator
885 .try_grant(PHYSICAL_MS_MAX, LOGICAL_MAX + 1)
886 .unwrap();
887 assert!(!allocator.would_grant(PHYSICAL_MS_MAX, 1));
888 }
889
890 #[test]
891 fn default_constructs_not_leader_allocator() {
892 let allocator = Allocator::default();
893 assert!(!allocator.is_leader());
894 assert_eq!(allocator.epoch(), None);
895 }
896
897 #[test]
898 fn logical_wraps_to_next_physical_ms() {
899 let mut allocator = Allocator::new();
900 // fence_floor=0, ceiling=0; extend to 10 before granting.
901 allocator.try_on_leadership_gained(0, 0, Epoch(1)).unwrap();
902 allocator.try_commit_window_extension(10, Epoch(1)).unwrap();
903 // Issue LOGICAL_MAX+1 logicals at physical_ms=1, then one more should bump to 2.
904 let first = allocator.try_grant(1, LOGICAL_MAX + 1).unwrap();
905 assert_eq!(first.physical_ms, 1);
906 assert_eq!(first.logical_start, 0);
907 let second = allocator.try_grant(1, 1).unwrap();
908 assert_eq!(second.physical_ms, 2);
909 assert_eq!(second.logical_start, 0);
910 }
911
912 #[test]
913 fn exact_fill_grant_normalizes_stored_state_to_packable() {
914 // A grant that consumes a millisecond's entire logical range
915 // (logical_start + count == LOGICAL_MAX + 1) must not leave the
916 // LOGICAL_MAX+1 sentinel in next_logical: that value cannot be packed
917 // (Timestamp::pack asserts logical <= LOGICAL_MAX), so the stored state
918 // would only be safe by the implicit "next call always wraps" invariant.
919 // The write-back normalizes it to the already-rolled position
920 // (physical_ms + 1, 0), so stored state is always directly packable.
921 let mut allocator = Allocator::new();
922 allocator.try_on_leadership_gained(0, 0, Epoch(1)).unwrap();
923 allocator.try_commit_window_extension(10, Epoch(1)).unwrap();
924 // Fill physical_ms=1 exactly: logical [0, LOGICAL_MAX].
925 let grant = allocator.try_grant(1, LOGICAL_MAX + 1).unwrap();
926 assert_eq!(grant.physical_ms, 1);
927 assert_eq!(grant.logical_start, 0);
928
929 let State::Leader {
930 next_physical_ms,
931 next_logical,
932 ..
933 } = allocator.state
934 else {
935 panic!("expected leader state after a successful grant");
936 };
937 // The stored cursor rolled to the next millisecond at logical 0 …
938 assert_eq!(next_physical_ms, 2);
939 assert_eq!(next_logical, 0);
940 // … and is, by construction, a packable timestamp.
941 assert!(Timestamp::try_pack(next_physical_ms, next_logical).is_ok());
942 }
943
944 #[test]
945 fn try_new_accepts_valid_grant_and_packs_boundaries() {
946 // A checked grant exposes its fields through accessors and its
947 // boundary timestamps pack without panicking — first() at
948 // logical_start, last() at logical_start + count - 1.
949 let grant = WindowGrant::try_new(1_000, 5, 3, Epoch(7)).unwrap();
950 assert_eq!(grant.physical_ms(), 1_000);
951 assert_eq!(grant.logical_start(), 5);
952 assert_eq!(grant.count(), 3);
953 assert_eq!(grant.epoch(), Epoch(7));
954 assert_eq!(grant.first(), Timestamp::pack(1_000, 5));
955 assert_eq!(grant.last(), Timestamp::pack(1_000, 7));
956 }
957
958 #[test]
959 fn try_new_accepts_max_logical_boundary() {
960 // logical_start + count - 1 == LOGICAL_MAX is the widest in-range grant.
961 let grant = WindowGrant::try_new(1_000, 0, LOGICAL_MAX + 1, Epoch(1)).unwrap();
962 assert_eq!(grant.last(), Timestamp::pack(1_000, LOGICAL_MAX));
963 }
964
965 #[test]
966 fn try_new_rejects_zero_count() {
967 // count == 0 would underflow logical_start + count - 1 in last().
968 assert_eq!(
969 WindowGrant::try_new(1_000, 0, 0, Epoch(1)),
970 Err(CoreError::InvalidCount(0))
971 );
972 }
973
974 #[test]
975 fn try_new_rejects_out_of_range_physical_ms() {
976 assert_eq!(
977 WindowGrant::try_new(PHYSICAL_MS_MAX + 1, 0, 1, Epoch(1)),
978 Err(CoreError::PhysicalMsOutOfRange(PHYSICAL_MS_MAX + 1))
979 );
980 }
981
982 #[test]
983 fn try_new_rejects_logical_range_overflow() {
984 // last logical (logical_start + count - 1) exceeds the 18-bit field.
985 assert_eq!(
986 WindowGrant::try_new(1_000, LOGICAL_MAX, 2, Epoch(1)),
987 Err(CoreError::LogicalRangeOutOfRange {
988 logical_start: LOGICAL_MAX,
989 count: 2
990 })
991 );
992 }
993
994 #[test]
995 fn try_new_rejects_logical_count_u32_overflow() {
996 // logical_start + (count - 1) overflows u32 before any range check.
997 assert_eq!(
998 WindowGrant::try_new(1_000, u32::MAX, 2, Epoch(1)),
999 Err(CoreError::LogicalRangeOutOfRange {
1000 logical_start: u32::MAX,
1001 count: 2
1002 })
1003 );
1004 }
1005}