diff --git a/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs b/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs index 57902b35e5c9..54ed3c12bb62 100644 --- a/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs +++ b/crates/sui-e2e-tests/tests/rpc/v2/ledger_service/list_ledger_history.rs @@ -992,6 +992,88 @@ async fn test_list_transactions_unfiltered_and_sender_filter() { ); } +/// (after = item n, before = item n + 1) serves nothing and ends with a CursorBound frame from the +/// ascending stop side (before cursor.) +#[sim_test] +async fn test_list_transactions_after_before_adjacent_empty_results() { + let cluster = new_cluster().await; + let sender = cluster.get_address_0(); + let tx = transfer_self(&cluster, sender).await; + let (start, end) = checkpoint_range(&[&tx]); + + let mut client = new_ledger_client(&cluster).await; + + let mut req = ListTransactionsRequest::default(); + req.read_mask = Some(FieldMask::from_paths(["digest"])); + req.start_checkpoint = Some(start); + req.end_checkpoint = Some(end); + req.options = Some(query_options(100)); + let baseline = list_transactions_result(&mut client, req).await; + assert!(baseline.end); + assert!( + baseline.transactions.len() >= 2, + "need at least two transactions for adjacency windows" + ); + + let after = first_transaction_cursor(&baseline, "first item cursor"); + let before = baseline.transactions[1] + .watermark + .as_ref() + .and_then(|w| w.cursor.clone()) + .expect("second item cursor"); + let mut req = ListTransactionsRequest::default(); + req.read_mask = Some(FieldMask::from_paths(["digest"])); + req.start_checkpoint = Some(start); + req.end_checkpoint = Some(end); + req.options = Some(query_options_between(3, after.clone(), before)); + let resp = list_transactions_result(&mut client, req).await; + assert!(resp.transactions.is_empty()); + assert!(resp.end); + assert_eq!(resp.end_reason, Some(QueryEndReason::CursorBound)); + // The frame is the before side's stamp (a Boundary token at its coordinate, + // pinned exactly at unit level); opaquely: present, and not the after side. + assert!(resp.end_cursor.is_some()); + assert_ne!(resp.end_cursor, Some(after)); +} + +/// When the `after` cursor falls on or after the last item of the window, nothing is served and +/// ends in CheckpointBound frame. +#[sim_test] +async fn test_list_transactions_after_last_item_ends_at_window_bound() { + let cluster = new_cluster().await; + let sender = cluster.get_address_0(); + let tx = transfer_self(&cluster, sender).await; + let (start, end) = checkpoint_range(&[&tx]); + + let mut client = new_ledger_client(&cluster).await; + + let mut req = ListTransactionsRequest::default(); + req.read_mask = Some(FieldMask::from_paths(["digest"])); + req.start_checkpoint = Some(start); + req.end_checkpoint = Some(end); + req.options = Some(query_options(100)); + let baseline = list_transactions_result(&mut client, req).await; + assert!(baseline.end); + + let last_cursor = last_transaction_cursor(&baseline, "last item cursor"); + + let mut req = ListTransactionsRequest::default(); + req.read_mask = Some(FieldMask::from_paths(["digest"])); + req.start_checkpoint = Some(start); + req.end_checkpoint = Some(end); + req.options = Some(query_options_after(3, last_cursor.clone())); + let resp = list_transactions_result(&mut client, req).await; + + assert!(resp.transactions.is_empty()); + assert!(resp.end); + assert_eq!(resp.end_reason, Some(QueryEndReason::CheckpointBound)); + assert_ne!( + resp.end_cursor, + Some(last_cursor), + "the drain frame carries the window's cursor, not the request echo" + ); +} + #[sim_test] async fn test_list_transactions_rich_mask_matches_get_transaction() { let cluster = new_cluster().await; diff --git a/crates/sui-rpc-api/src/ledger_history/query_options.rs b/crates/sui-rpc-api/src/ledger_history/query_options.rs index 6e7db5d17849..1537dc70a023 100644 --- a/crates/sui-rpc-api/src/ledger_history/query_options.rs +++ b/crates/sui-rpc-api/src/ledger_history/query_options.rs @@ -100,12 +100,9 @@ pub struct ResolvedScan

{ } /// How a lane reads wire cursors: the token's coordinate in the lane's -/// position space, and the lo-bound its resume admits. +/// position space. pub trait ScanCursor

{ fn coordinate(&self) -> P; - /// Dense lanes canonicalize an Item cursor to its inclusive successor; - /// lanes without a successor keep the exclusive raw bound. - fn resume_lo(&self) -> Bound

; } impl IntraTxCoordinate { @@ -422,7 +419,13 @@ where self.entry_checkpoint = self.entry_checkpoint.max(checkpoint); } - let candidate = cursor.resume_lo(); + // Symbolic resume in every lane: an Item admits strictly-after, a + // Boundary admits from itself; successor arithmetic exists only at + // the store edge. + let candidate = match cursor.kind { + sui_rpc_cursor::CursorKind::Item => Bound::Excluded(position), + sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position), + }; if !lower_bound_gte(candidate, self.bounds.lo) { return None; @@ -632,18 +635,6 @@ impl ScanCursor for CursorToken { Position::Events { .. } => unreachable!("validated at decode"), } } - - /// Item cursors resume at their inclusive successor. Saturating at - /// `u64::MAX` is exact, not lossy: no half-open scan range can contain an - /// item at MAX, so `Included(MAX)` admits nothing and the interval - /// resolves empty through the ordinary bounds check. - fn resume_lo(&self) -> Bound { - let position: u64 = self.coordinate(); - match self.kind { - sui_rpc_cursor::CursorKind::Item => Bound::Included(position.saturating_add(1)), - sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position), - } - } } impl ScanCursor for CursorToken { @@ -660,14 +651,6 @@ impl ScanCursor for CursorToken { _ => unreachable!("validated at decode"), } } - - fn resume_lo(&self) -> Bound { - let position: IntraTxCoordinate = self.coordinate(); - match self.kind { - sui_rpc_cursor::CursorKind::Item => Bound::Excluded(position), - sui_rpc_cursor::CursorKind::Boundary => Bound::Included(position), - } - } } impl From for (u64, u32) { @@ -1034,6 +1017,25 @@ mod tests { assert_eq!(bounds.tx_range(), None); } + /// Every after-Item cursor now resumes as an Excluded lo; the successor + /// arithmetic and its `u64::MAX` saturation live here at the store edge. + #[test] + fn to_range_collapses_excluded_lo_at_successor() { + let bounds = ScanBounds { + lo: Bound::Excluded(14u64), + hi: Bound::Excluded(20u64), + }; + assert_eq!(bounds.to_range(), 15..20); + + // The saturated successor at the unoccupiable sentinel admits + // nothing, through the ordinary emptiness of MAX..MAX. + let bounds = ScanBounds { + lo: Bound::Excluded(u64::MAX), + hi: Bound::Unbounded, + }; + assert!(bounds.to_range().is_empty()); + } + #[test] fn parses_cursors_and_ordering() { let after = tx_item(2, 20).encode(); @@ -1181,24 +1183,36 @@ mod tests { } ); assert_eq!(bounded.end_position, 11); + } + // (descending, after = Item 11, before = Item 12): the cursors leave the + // empty gap (11, 12), so the fetch serves nothing and the CursorBound + // frame comes from the descending stop side (the after cursor, at 11). + #[test] + fn descending_before_adjacent_to_after_empties_at_after_cursor() { let options = QueryOptions { + limit_items: 2, + ordering: Ordering::Descending, + after: Some(tx_item(1, 11)), before: Some(tx_item(1, 12)), - ..options }; + let crossed = resolved_range(10..20).apply_cursor_bounds(&options); assert_eq!( - resolved_range(10..20).apply_cursor_bounds(&options), + crossed, ResolvedScan { + bounds: ScanBounds { + lo: Bound::Excluded(11), + hi: Bound::Excluded(12), + }, entry_checkpoint: 0, - ..empty_resolved_range( - 1, - 12, - RangeExhaustion::CursorBound { - kind: sui_rpc_cursor::CursorKind::Boundary, - }, - ) + end_checkpoint: 1, + end_position: 11, + exhaustion: RangeExhaustion::CursorBound { + kind: sui_rpc_cursor::CursorKind::Boundary, + }, } ); + assert!(crossed.range().is_empty()); } #[test] @@ -1909,26 +1923,37 @@ mod tests { ); } - /// (ascending, after inside the window): Item admits strictly after its - /// coordinate, Boundary from it; entry checkpoint rises; terminal untouched. + // An after-Item cursor makes the exclusive lower bound one past the cursor, while an + // after-Boundary cursor makes the lower bound equal to the cursor. #[test] fn tx_after_cursor_tightens_ascending_lower_bound() { - // Item at n resumes at n + 1; Boundary at n resumes at n. + // Item at n admits strictly-after symbolically; the successor lives + // at the store edge, so both forms fetch from n + 1. let options = after_options(Ordering::Ascending, tx_item(3, 12)); + // Resolves to `(12, 20)` + let tightened = resolved_range(10..20).apply_cursor_bounds(&options); assert_eq!( - resolved_range(10..20).apply_cursor_bounds(&options), + tightened, ResolvedScan { - bounds: ScanBounds::from_range(13..20), + bounds: ScanBounds { + lo: Bound::Excluded(12), + hi: Bound::Excluded(20), + }, entry_checkpoint: 3, ..resolved_range(10..20) } ); + // Projection into Range sets the lower bound as n + 1 + assert_eq!(tightened.range(), 13..20); let options = after_options(Ordering::Ascending, tx_boundary(3, 12)); assert_eq!( resolved_range(10..20).apply_cursor_bounds(&options), ResolvedScan { - bounds: ScanBounds::from_range(12..20), + bounds: ScanBounds { + lo: Bound::Included(12), + hi: Bound::Excluded(20), + }, entry_checkpoint: 3, ..resolved_range(10..20) } @@ -1945,7 +1970,10 @@ mod tests { assert_eq!( resolved_range(10..20).apply_cursor_bounds(&options), ResolvedScan { - bounds: ScanBounds::from_range(13..20), + bounds: ScanBounds { + lo: Bound::Excluded(12), + hi: Bound::Excluded(20), + }, entry_checkpoint: 0, end_checkpoint: 3, end_position: 12, @@ -2107,31 +2135,6 @@ mod tests { ); } - #[test] - fn item_cursor_can_be_used_as_after_or_before() { - let token = CursorToken::item(Position::Transactions { - checkpoint: 1, - tx_seq: 11, - }) - .encode(); - - let mut request = ProtoQueryOptions::default(); - request.after = Some(token.clone()); - let options = query_options_from_proto(Some(&request)).unwrap(); - assert_eq!( - resolved_range(10..20).apply_cursor_bounds(&options).range(), - 12..20 - ); - - request.after = None; - request.before = Some(token); - let options = query_options_from_proto(Some(&request)).unwrap(); - assert_eq!( - resolved_range(10..20).apply_cursor_bounds(&options).range(), - 10..11 - ); - } - /// Resume-equivalence witness for the raw cursor echo: an after-Item at n /// admits exactly what the successor-form echo it replaced (Boundary at /// n + 1) admits, so feeding either token back scans the same interval. @@ -2143,12 +2146,12 @@ mod tests { let item = after_options(Ordering::Ascending, tx_item(3, 24)); let successor = after_options(Ordering::Ascending, tx_boundary(3, 25)); - // Non-empty remainder: identical scan bounds either way. + // Item(3, 24) results in the same range as Boundary(3, 25) assert_eq!( - resolved_range(10..30).apply_cursor_bounds(&item).bounds, + resolved_range(10..30).apply_cursor_bounds(&item).range(), resolved_range(10..30) .apply_cursor_bounds(&successor) - .bounds, + .range(), ); // Drained window: both resumptions admit nothing. @@ -2159,4 +2162,90 @@ mod tests { .is_empty() ); } + + /// For some ResolvedScan, if its bounds are Excluded(A) and Excluded(A + 1), its bounds + /// report non-empty, but its range is empty. + /// `test_list_transactions_after_last_item_ends_at_window_bound` checks that the result set is + /// empty. + #[test] + fn tx_after_item_at_window_edge_defers_to_drain() { + let options = after_options(Ordering::Ascending, tx_item(5, 19)); + let initial_range = resolved_range(10..20); + let resolved = initial_range.clone().apply_cursor_bounds(&options); + assert_eq!( + resolved, + ResolvedScan { + bounds: ScanBounds { + lo: Bound::Excluded(19), + hi: Bound::Excluded(20), + }, + entry_checkpoint: 5, + ..initial_range + } + ); + assert!(!resolved.is_empty()); + assert!(resolved.range().is_empty()); + } + + /// Exclusive after on descending bumps the bounds, and sets the end_checkpoint, end_position, + /// and exhaustion to the after cursor. + #[test] + fn tx_after_item_at_window_edge_descending_keeps_stamped_terminal() { + let options = after_options(Ordering::Descending, tx_item(5, 19)); + let initial_range = resolved_range(10..20); + let resolved = initial_range.clone().apply_cursor_bounds(&options); + assert_eq!( + resolved, + ResolvedScan { + bounds: ScanBounds { + lo: Bound::Excluded(19), + hi: Bound::Excluded(20), + }, + end_checkpoint: 5, + end_position: 19, + exhaustion: RangeExhaustion::CursorBound { + kind: sui_rpc_cursor::CursorKind::Boundary, + }, + ..initial_range + } + ); + assert!(resolved.range().is_empty()); + } + + /// On a descending scan, when the after-Item cursor is exactly on the terminating window, the + /// terminal values remain unchanged; the cursor does not overwrite the original bounds as it is + /// not additionally restrictive. + #[test] + fn tx_after_item_below_window_start_descending_is_noop() { + let options = after_options(Ordering::Descending, tx_item(5, 9)); + assert_eq!( + resolved_range(10..20).apply_cursor_bounds(&options), + resolved_range(10..20) + ); + } + + #[test] + fn item_cursor_can_be_used_as_after_or_before() { + let token = CursorToken::item(Position::Transactions { + checkpoint: 1, + tx_seq: 11, + }) + .encode(); + + let mut request = ProtoQueryOptions::default(); + request.after = Some(token.clone()); + let options = query_options_from_proto(Some(&request)).unwrap(); + assert_eq!( + resolved_range(10..20).apply_cursor_bounds(&options).range(), + 12..20 + ); + + request.after = None; + request.before = Some(token); + let options = query_options_from_proto(Some(&request)).unwrap(); + assert_eq!( + resolved_range(10..20).apply_cursor_bounds(&options).range(), + 10..11 + ); + } }