1use 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
33fn set_checkpoint_bound(wm: &mut Watermark, boundary: Option<u64>) {
38 wm.checkpoint = boundary;
39}
40
41pub 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
56pub 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
94pub 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
106pub fn boundary_watermark(position: Position, boundary: Option<u64>) -> Watermark {
112 cursor_watermark(position, boundary, sui_rpc_cursor::CursorKind::Boundary)
113}
114
115fn 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
134pub 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
148pub 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
166fn 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
192pub enum NaturalRangeEnd {
193 LedgerTip,
194 CheckpointBound,
195}
196
197#[derive(Clone, Debug, PartialEq)]
198pub enum ScanTerminal {
199 ScanLimit { watermark: Watermark },
202 NaturalRange {
204 end: NaturalRangeEnd,
205 position: Position,
206 interval_empty: bool,
212 },
213 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 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 #[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 #[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 #[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 #[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 #[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 #[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}