Skip to main content

sui_rpc_api/ledger_history/
watermark.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared `Watermark` construction for the v2 list APIs.
5//!
6//! Both ledger-history backends — the fullnode (`sui-rpc-api`) and bigtable
7//! (`sui-kv-rpc`) — and all three list handlers (`list_transactions`,
8//! `list_events`, `list_checkpoints`) emit the same wire `Watermark`: a resume
9//! cursor plus a completion boundary (`checkpoint`, the inclusive boundary
10//! checkpoint the scan has fully covered in the request's ordering direction).
11//! The cursor encoding and the boundary bookkeeping are identical; what differs
12//! per API is how a scan position resolves into a completion-boundary candidate:
13//!
14//! - `list_transactions` / `list_events` scan within a checkpoint, so an
15//!   item at checkpoint `C` does NOT prove `C` complete (more matches may sit
16//!   at higher/lower transaction or event positions). Their covered bound is
17//!   advanced before `C` — see [`advance_covered_bound_before_checkpoint`].
18//! - `list_checkpoints` dedupes checkpoint numbers, so "checkpoint `C`
19//!   emitted" means "checkpoint `C` complete." Its item path directly records
20//!   `C`; independently resolved frontier candidates are folded with
21//!   [`merge_covered_checkpoint_bound`].
22//!
23//! This module owns the shared pieces; each handler keeps only its
24//! API-specific frontier-to-candidate adapter.
25
26use sui_inverted_index::ScanDirection;
27use sui_rpc::proto::sui::rpc::v2::QueryEndReason;
28use sui_rpc::proto::sui::rpc::v2::Watermark;
29use sui_rpc_cursor::{CursorKind, CursorToken, Position};
30
31use crate::ledger_history::query_options::{QueryOptions, RangeExhaustion};
32
33/// Populate the completion-boundary `checkpoint` field of a `Watermark` from
34/// the per-scan boundary value. The value already carries the direction-correct
35/// meaning (inclusive upper bound ascending, inclusive lower bound descending);
36/// the single wire field records it regardless of ordering.
37fn set_checkpoint_bound(wm: &mut Watermark, boundary: Option<u64>) {
38    wm.checkpoint = boundary;
39}
40
41/// Merge a fully covered checkpoint candidate into the accumulated inclusive
42/// bound. The bound advances by max in ascending scans and min in descending
43/// scans.
44pub fn merge_covered_checkpoint_bound(
45    covered_checkpoint_bound: Option<u64>,
46    candidate_bound: u64,
47    options: &QueryOptions,
48) -> Option<u64> {
49    Some(match covered_checkpoint_bound {
50        None => candidate_bound,
51        Some(bound) if options.is_ascending() => bound.max(candidate_bound),
52        Some(bound) => bound.min(candidate_bound),
53    })
54}
55
56/// Advance the inclusive covered bound using a checkpoint that is not itself
57/// proven complete. Transactions, events, and scan frontiers can leave more
58/// matches within checkpoint `C`, so the candidate excludes `C`: `C - 1`
59/// ascending and `C + 1` descending. The adjusted candidate is then merged by
60/// max ascending or min descending.
61///
62/// `entry_checkpoint` is the checkpoint containing the effective interval's
63/// first position in scan direction (fixed at range-resolution time). A
64/// candidate strictly before it proves nothing — the scan is still inside its
65/// first checkpoint — and is discarded, keeping the wire `checkpoint` field
66/// unset until the scan's first checkpoint is fully covered, as the proto
67/// contract requires.
68///
69/// When the `C ∓ 1` adjustment would overflow (`C == 0` ascending or
70/// `u64::MAX` descending), the previously covered bound is preserved.
71pub fn advance_covered_bound_before_checkpoint(
72    covered_checkpoint_bound: Option<u64>,
73    incomplete_checkpoint: u64,
74    entry_checkpoint: u64,
75    options: &QueryOptions,
76) -> Option<u64> {
77    let candidate_bound = if options.is_ascending() {
78        incomplete_checkpoint
79            .checked_sub(1)
80            .filter(|candidate| *candidate >= entry_checkpoint)
81    } else {
82        incomplete_checkpoint
83            .checked_add(1)
84            .filter(|candidate| *candidate <= entry_checkpoint)
85    };
86    match candidate_bound {
87        Some(candidate_bound) => {
88            merge_covered_checkpoint_bound(covered_checkpoint_bound, candidate_bound, options)
89        }
90        None => covered_checkpoint_bound,
91    }
92}
93
94/// Build the embedded `Watermark` for an item: the cursor encodes this
95/// item's position (so the next request's `after`/`before` resumes past it)
96/// plus the current direction-matching checkpoint boundary. `cp` /
97/// `position` are the item's cursor coordinates (`list_checkpoints` passes
98/// its cp_seq for both).
99pub fn item_watermark(position: Position, boundary: Option<u64>) -> Watermark {
100    let mut wm = Watermark::default();
101    wm.cursor = Some(CursorToken::item(position).encode());
102    set_checkpoint_bound(&mut wm, boundary);
103    wm
104}
105
106/// Build a standalone scan-frontier `Watermark`. `cursor_cp` / `position`
107/// are the boundary cursor coordinates the caller has already resolved for
108/// its scan domain (see [`boundary_cursor_cp`] for the per-checkpoint
109/// scanners' direction adjustment); `boundary` is the accumulated
110/// completion boundary.
111pub fn boundary_watermark(position: Position, boundary: Option<u64>) -> Watermark {
112    cursor_watermark(position, boundary, sui_rpc_cursor::CursorKind::Boundary)
113}
114
115/// Build a watermark whose cursor kind has been resolved by query-range
116/// bookkeeping. This is needed for an ascending event interval made empty by
117/// an `after` Item cursor, where changing the raw coordinate to Boundary would
118/// re-include the item on resume.
119fn cursor_watermark(
120    position: Position,
121    boundary: Option<u64>,
122    cursor_kind: sui_rpc_cursor::CursorKind,
123) -> Watermark {
124    let cursor = match cursor_kind {
125        sui_rpc_cursor::CursorKind::Item => CursorToken::item(position),
126        sui_rpc_cursor::CursorKind::Boundary => CursorToken::boundary(position),
127    };
128    let mut wm = Watermark::default();
129    wm.cursor = Some(cursor.encode());
130    set_checkpoint_bound(&mut wm, boundary);
131    wm
132}
133
134/// Resolve the boundary-cursor checkpoint coordinate for a `list_transactions`
135/// / `list_events` scan frontier. The cursor encoding is asymmetric:
136/// ascending `Boundary` cursors advance the cp-range start, so the frontier
137/// cp is used directly; descending `Boundary` cursors treat the cp
138/// coordinate as an EXCLUSIVE upper bound, so `cp + 1` is needed to keep
139/// `cp` itself included on resume.
140pub fn boundary_cursor_cp(cp: u64, direction: ScanDirection) -> u64 {
141    if direction.is_ascending() {
142        cp
143    } else {
144        cp.saturating_add(1)
145    }
146}
147
148/// Resolve the checkpoint coordinate embedded in a transaction/event/checkpoint
149/// scan-frontier cursor independently from the optional completed-checkpoint
150/// claim. A missing mapping is representable only at the numeric edge where
151/// the frontier itself supplies the sole safe checkpoint coordinate.
152pub fn scan_frontier_cursor_cp(
153    checkpoint: Option<u64>,
154    frontier: u64,
155    direction: ScanDirection,
156) -> Option<u64> {
157    checkpoint
158        .map(|cp| boundary_cursor_cp(cp, direction))
159        .or_else(|| {
160            ((direction.is_ascending() && frontier == 0)
161                || (!direction.is_ascending() && frontier == u64::MAX))
162                .then_some(frontier)
163        })
164}
165
166/// Boundary watermark emitted once a scan has drained its entire resolved
167/// range under natural completion. Unlike per-item watermarks it can claim
168/// the range's final checkpoint complete — `end_checkpoint - 1` ascending
169/// (the exclusive cp upper) or `end_checkpoint` descending (the inclusive cp
170/// lower) — because no further items exist in it within the requested range.
171/// The `(end_checkpoint, end_position)` cursor resumes exactly past the
172/// scanned range.
173fn terminal_boundary_watermark(options: &QueryOptions, end_position: Position) -> Watermark {
174    let end_checkpoint = end_position.checkpoint();
175    let boundary = if options.is_ascending() {
176        end_checkpoint.checked_sub(1)
177    } else {
178        Some(end_checkpoint)
179    };
180    let mut wm = Watermark::default();
181    wm.cursor = Some(CursorToken::boundary(end_position).encode());
182    set_checkpoint_bound(&mut wm, boundary);
183    wm
184}
185
186/// Terminal of a successful list scan that renders as the trailing
187/// payload-free `QueryEnd` frame. `ItemLimit` never reaches this type: the
188/// drive loops fuse it onto the final item frame and suppress the trailing
189/// frame. The wire reason and the watermark policy are projections of the
190/// same value, so they cannot disagree.
191#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum NaturalRangeEnd {
193    LedgerTip,
194    CheckpointBound,
195}
196
197#[derive(Clone, Debug, PartialEq)]
198pub enum ScanTerminal {
199    /// Scan budget exhausted. Owns the mandatory authoritative frontier
200    /// watermark (its cursor is always set by the frontier constructors).
201    ScanLimit { watermark: Watermark },
202    /// The resolved interval is naturally exhausted at `position`.
203    NaturalRange {
204        end: NaturalRangeEnd,
205        position: Position,
206        /// The resolved interval contained no scannable positions. Natural
207        /// completion of an empty interval covered nothing, so its terminal
208        /// claim stays unset (the proto contract keeps `checkpoint` unset
209        /// until the scan's first checkpoint is fully covered); the resume
210        /// cursor is unaffected.
211        interval_empty: bool,
212    },
213    /// The caller-provided cursor bound truncates the interval at `position`.
214    CursorBound {
215        position: Position,
216        kind: CursorKind,
217    },
218}
219
220impl ScanTerminal {
221    pub fn from_range_exhaustion(
222        exhaustion: RangeExhaustion,
223        position: Position,
224        interval_empty: bool,
225    ) -> Self {
226        match exhaustion {
227            RangeExhaustion::LedgerTip => Self::NaturalRange {
228                end: NaturalRangeEnd::LedgerTip,
229                position,
230                interval_empty,
231            },
232            RangeExhaustion::CheckpointBound => Self::NaturalRange {
233                end: NaturalRangeEnd::CheckpointBound,
234                position,
235                interval_empty,
236            },
237            RangeExhaustion::CursorBound { kind } => Self::CursorBound { position, kind },
238        }
239    }
240
241    pub fn reason(&self) -> QueryEndReason {
242        match self {
243            Self::ScanLimit { .. } => QueryEndReason::ScanLimit,
244            Self::NaturalRange {
245                end: NaturalRangeEnd::LedgerTip,
246                ..
247            } => QueryEndReason::LedgerTip,
248            Self::NaturalRange {
249                end: NaturalRangeEnd::CheckpointBound,
250                ..
251            } => QueryEndReason::CheckpointBound,
252            Self::CursorBound { .. } => QueryEndReason::CursorBound,
253        }
254    }
255
256    /// Render the trailing terminal frame's watermark. Natural completion
257    /// (LedgerTip/CheckpointBound) of a scanned interval claims the range's
258    /// final checkpoint via `terminal_boundary_watermark` and ignores
259    /// `covered_checkpoint_bound` (the range claim is always at least as
260    /// strong); an empty interval covered nothing and claims nothing. A
261    /// cursor bound never claims its own checkpoint: its claim is exactly
262    /// the accumulated item coverage.
263    pub fn into_watermark(
264        self,
265        options: &QueryOptions,
266        covered_checkpoint_bound: Option<u64>,
267    ) -> Watermark {
268        match self {
269            Self::ScanLimit { watermark } => watermark,
270            Self::NaturalRange {
271                position,
272                interval_empty,
273                ..
274            } => {
275                if interval_empty {
276                    boundary_watermark(position, None)
277                } else {
278                    terminal_boundary_watermark(options, position)
279                }
280            }
281            Self::CursorBound { position, kind } => {
282                cursor_watermark(position, covered_checkpoint_bound, kind)
283            }
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use sui_rpc_cursor::{CursorKind, Position};
292
293    fn options(ascending: bool) -> QueryOptions {
294        let mut request = sui_rpc::proto::sui::rpc::v2::QueryOptions::default();
295        request.ordering = Some(if ascending {
296            sui_rpc::proto::sui::rpc::v2::Ordering::Ascending as i32
297        } else {
298            sui_rpc::proto::sui::rpc::v2::Ordering::Descending as i32
299        });
300        QueryOptions::transactions_from_proto(Some(&request), 100, 100).unwrap()
301    }
302
303    #[test]
304    fn merge_covered_checkpoint_bound_keeps_most_advanced_in_direction() {
305        let asc = options(true);
306        assert_eq!(merge_covered_checkpoint_bound(None, 5, &asc), Some(5));
307        assert_eq!(merge_covered_checkpoint_bound(Some(5), 9, &asc), Some(9));
308        assert_eq!(merge_covered_checkpoint_bound(Some(9), 5, &asc), Some(9));
309
310        let desc = options(false);
311        assert_eq!(merge_covered_checkpoint_bound(None, 9, &desc), Some(9));
312        assert_eq!(merge_covered_checkpoint_bound(Some(9), 5, &desc), Some(5));
313        assert_eq!(merge_covered_checkpoint_bound(Some(5), 9, &desc), Some(5));
314    }
315
316    /// The per-checkpoint scanners exclude the item's own cp: `C - 1`
317    /// ascending, `C + 1` descending.
318    #[test]
319    fn advance_covered_bound_before_checkpoint_adjusts_by_one() {
320        let asc = options(true);
321        assert_eq!(
322            advance_covered_bound_before_checkpoint(None, 10, 5, &asc),
323            Some(9)
324        );
325        assert_eq!(
326            advance_covered_bound_before_checkpoint(Some(9), 12, 5, &asc),
327            Some(11)
328        );
329
330        let desc = options(false);
331        assert_eq!(
332            advance_covered_bound_before_checkpoint(None, 10, 15, &desc),
333            Some(11)
334        );
335        assert_eq!(
336            advance_covered_bound_before_checkpoint(Some(11), 8, 15, &desc),
337            Some(9)
338        );
339    }
340
341    /// While the scan is still inside its first checkpoint of the effective
342    /// interval, the fencepost candidate falls before the entry checkpoint and
343    /// must be discarded: the wire `checkpoint` field stays unset until the
344    /// scan's first checkpoint is fully covered (proto contract). The claim at
345    /// exactly the entry checkpoint (candidate == entry) is the first legal
346    /// one.
347    #[test]
348    fn advance_covered_bound_before_checkpoint_stays_unset_within_entry_checkpoint() {
349        let asc = options(true);
350        assert_eq!(
351            advance_covered_bound_before_checkpoint(None, 10, 10, &asc),
352            None
353        );
354        assert_eq!(
355            advance_covered_bound_before_checkpoint(None, 11, 10, &asc),
356            Some(10)
357        );
358
359        let desc = options(false);
360        assert_eq!(
361            advance_covered_bound_before_checkpoint(None, 10, 10, &desc),
362            None
363        );
364        assert_eq!(
365            advance_covered_bound_before_checkpoint(None, 9, 10, &desc),
366            Some(10)
367        );
368    }
369
370    /// Overflow at the range edge (`cp 0` ascending, `u64::MAX` descending)
371    /// preserves the previously accumulated boundary instead of dropping it.
372    #[test]
373    fn advance_covered_bound_before_checkpoint_preserves_prev_on_overflow() {
374        let asc = options(true);
375        assert_eq!(
376            advance_covered_bound_before_checkpoint(Some(4), 0, 0, &asc),
377            Some(4)
378        );
379        assert_eq!(
380            advance_covered_bound_before_checkpoint(None, 0, 0, &asc),
381            None
382        );
383
384        let desc = options(false);
385        assert_eq!(
386            advance_covered_bound_before_checkpoint(Some(4), u64::MAX, u64::MAX, &desc),
387            Some(4)
388        );
389        assert_eq!(
390            advance_covered_bound_before_checkpoint(None, u64::MAX, u64::MAX, &desc),
391            None
392        );
393    }
394
395    #[test]
396    fn boundary_cursor_cp_bumps_descending_only() {
397        assert_eq!(boundary_cursor_cp(10, ScanDirection::Ascending), 10);
398        assert_eq!(boundary_cursor_cp(10, ScanDirection::Descending), 11);
399        assert_eq!(
400            boundary_cursor_cp(u64::MAX, ScanDirection::Descending),
401            u64::MAX
402        );
403    }
404
405    /// The direction-correct boundary is recorded in the single `checkpoint` field regardless of
406    /// ordering. A client reads the bound off the wire frame and interprets it per the request's
407    /// ordering.
408    #[test]
409    fn item_watermark_sets_direction_matching_bound() {
410        let pos = Position::Transactions {
411            checkpoint: 9,
412            tx_seq: 42,
413        };
414        let wm = item_watermark(pos, Some(8));
415        assert_eq!(wm.checkpoint, Some(8));
416        assert_eq!(wm.cursor.as_ref(), Some(&CursorToken::item(pos).encode()));
417
418        let wm = item_watermark(pos, None);
419        assert_eq!(wm.checkpoint, None);
420    }
421
422    /// On natural completion the terminal frame claims the range's final
423    /// checkpoint complete: ascending uses `end_checkpoint - 1` and resumes
424    /// from `(end_checkpoint, end_position)`; descending stores the range's
425    /// lowest checkpoint (inclusive). Both land in the single `checkpoint`
426    /// field.
427    #[test]
428    fn terminal_boundary_watermark_claims_final_checkpoint() {
429        let asc = options(true);
430        let pos = Position::Transactions {
431            checkpoint: 10,
432            tx_seq: 100,
433        };
434        let wm = terminal_boundary_watermark(&asc, pos);
435        assert_eq!(wm.checkpoint, Some(9));
436        assert_eq!(
437            wm.cursor.as_ref(),
438            Some(&CursorToken::boundary(pos).encode())
439        );
440
441        let desc = options(false);
442        let wm = terminal_boundary_watermark(&desc, pos);
443        assert_eq!(wm.checkpoint, Some(10));
444        assert_eq!(
445            wm.cursor.as_ref(),
446            Some(&CursorToken::boundary(pos).encode())
447        );
448    }
449
450    #[test]
451    fn scan_terminal_converts_every_range_exhaustion() {
452        let position = Position::Transactions {
453            checkpoint: 9,
454            tx_seq: 4,
455        };
456        let cases = [
457            (
458                RangeExhaustion::LedgerTip,
459                false,
460                ScanTerminal::NaturalRange {
461                    end: NaturalRangeEnd::LedgerTip,
462                    position,
463                    interval_empty: false,
464                },
465            ),
466            (
467                RangeExhaustion::CheckpointBound,
468                true,
469                ScanTerminal::NaturalRange {
470                    end: NaturalRangeEnd::CheckpointBound,
471                    position,
472                    interval_empty: true,
473                },
474            ),
475            (
476                RangeExhaustion::CursorBound {
477                    kind: CursorKind::Item,
478                },
479                false,
480                ScanTerminal::CursorBound {
481                    position,
482                    kind: CursorKind::Item,
483                },
484            ),
485            (
486                RangeExhaustion::CursorBound {
487                    kind: CursorKind::Item,
488                },
489                true,
490                ScanTerminal::CursorBound {
491                    position,
492                    kind: CursorKind::Item,
493                },
494            ),
495        ];
496
497        for (exhaustion, interval_empty, expected) in cases {
498            assert_eq!(
499                ScanTerminal::from_range_exhaustion(exhaustion, position, interval_empty),
500                expected
501            );
502        }
503    }
504
505    #[test]
506    fn scan_terminal_natural_range_uses_terminal_boundary_watermark() {
507        let ascending = options(true);
508        let descending = options(false);
509        let position = Position::Transactions {
510            checkpoint: 9,
511            tx_seq: 4,
512        };
513
514        let checkpoint_bound = ScanTerminal::NaturalRange {
515            end: NaturalRangeEnd::CheckpointBound,
516            position,
517            interval_empty: false,
518        };
519        assert_eq!(checkpoint_bound.reason(), QueryEndReason::CheckpointBound);
520        let watermark = checkpoint_bound.clone().into_watermark(&ascending, None);
521        assert_eq!(
522            watermark.cursor,
523            Some(CursorToken::boundary(position).encode())
524        );
525        assert_eq!(watermark.checkpoint, Some(8));
526        let watermark = checkpoint_bound.into_watermark(&descending, None);
527        assert_eq!(
528            watermark.cursor,
529            Some(CursorToken::boundary(position).encode())
530        );
531        assert_eq!(watermark.checkpoint, Some(9));
532
533        let ledger_tip = ScanTerminal::NaturalRange {
534            end: NaturalRangeEnd::LedgerTip,
535            position,
536            interval_empty: false,
537        };
538        assert_eq!(ledger_tip.reason(), QueryEndReason::LedgerTip);
539        let watermark = ledger_tip.clone().into_watermark(&ascending, Some(6));
540        assert_eq!(
541            watermark.cursor,
542            Some(CursorToken::boundary(position).encode())
543        );
544        assert_eq!(watermark.checkpoint, Some(8));
545        let watermark = ledger_tip.into_watermark(&descending, Some(6));
546        assert_eq!(
547            watermark.cursor,
548            Some(CursorToken::boundary(position).encode())
549        );
550        assert_eq!(watermark.checkpoint, Some(9));
551    }
552
553    /// Natural completion of an interval that resolved empty covered nothing:
554    /// the terminal cursor is unchanged but the checkpoint claim stays unset,
555    /// in both directions and for both natural reasons.
556    #[test]
557    fn scan_terminal_empty_natural_range_claims_nothing() {
558        let ascending = options(true);
559        let descending = options(false);
560        let position = Position::Transactions {
561            checkpoint: 9,
562            tx_seq: 4,
563        };
564
565        for (end, reason) in [
566            (
567                NaturalRangeEnd::CheckpointBound,
568                QueryEndReason::CheckpointBound,
569            ),
570            (NaturalRangeEnd::LedgerTip, QueryEndReason::LedgerTip),
571        ] {
572            let terminal = ScanTerminal::NaturalRange {
573                end,
574                position,
575                interval_empty: true,
576            };
577            assert_eq!(terminal.reason(), reason);
578            let watermark = terminal.clone().into_watermark(&ascending, None);
579            assert_eq!(
580                watermark.cursor,
581                Some(CursorToken::boundary(position).encode())
582            );
583            assert_eq!(watermark.checkpoint, None);
584            let watermark = terminal.into_watermark(&descending, None);
585            assert_eq!(
586                watermark.cursor,
587                Some(CursorToken::boundary(position).encode())
588            );
589            assert_eq!(watermark.checkpoint, None);
590        }
591    }
592
593    #[test]
594    fn scan_terminal_cursor_bound_preserves_coverage() {
595        let ascending = options(true);
596        let position = Position::Transactions {
597            checkpoint: 9,
598            tx_seq: 4,
599        };
600        let terminal = ScanTerminal::CursorBound {
601            position,
602            kind: CursorKind::Boundary,
603        };
604        assert_eq!(terminal.reason(), QueryEndReason::CursorBound);
605
606        let watermark = terminal.clone().into_watermark(&ascending, Some(6));
607        assert_eq!(
608            watermark.cursor,
609            Some(CursorToken::boundary(position).encode())
610        );
611        assert_eq!(watermark.checkpoint, Some(6));
612
613        let watermark = terminal.into_watermark(&ascending, None);
614        assert_eq!(
615            watermark.cursor,
616            Some(CursorToken::boundary(position).encode())
617        );
618        assert_eq!(watermark.checkpoint, None);
619    }
620
621    #[test]
622    fn scan_terminal_event_cursor_bound_preserves_item_kind() {
623        let ascending = options(true);
624        let position = Position::Events {
625            checkpoint: 9,
626            tx_seq: 4,
627            event_index: 2,
628        };
629        let terminal = ScanTerminal::CursorBound {
630            position,
631            kind: CursorKind::Item,
632        };
633        assert_eq!(terminal.reason(), QueryEndReason::CursorBound);
634
635        let watermark = terminal.into_watermark(&ascending, Some(6));
636        assert_eq!(watermark.cursor, Some(CursorToken::item(position).encode()));
637        assert_eq!(watermark.checkpoint, Some(6));
638    }
639
640    #[test]
641    fn scan_terminal_scan_limit_returns_owned_watermark() {
642        let ascending = options(true);
643        let descending = options(false);
644        let mut watermark = Watermark::default();
645        watermark.cursor = Some(b"scan-limit".to_vec().into());
646        watermark.checkpoint = Some(6);
647        let terminal = ScanTerminal::ScanLimit {
648            watermark: watermark.clone(),
649        };
650        assert_eq!(terminal.reason(), QueryEndReason::ScanLimit);
651        assert_eq!(terminal.into_watermark(&ascending, None), watermark);
652
653        let terminal = ScanTerminal::ScanLimit {
654            watermark: watermark.clone(),
655        };
656        assert_eq!(terminal.into_watermark(&descending, Some(99)), watermark);
657    }
658}