Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions sei-cosmos/types/query/filtered_pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (

"github.com/sei-protocol/sei-chain/sei-cosmos/codec"
"github.com/sei-protocol/sei-chain/sei-cosmos/store/types"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// FilteredPaginate does pagination of all the results in the PrefixStore based on the
Expand Down Expand Up @@ -36,11 +38,16 @@ func FilteredPaginate(
return nil, fmt.Errorf("invalid request, either offset or key is expected, got both")
}

if err := VerifyPaginationOffset(offset); err != nil {
return nil, err
}

if limit == 0 {
limit = DefaultLimit
}

// count total results when the limit is zero/not supplied
countTotal = true
if err := VerifyPaginationLimit(limit); err != nil {
return nil, err
}

if len(key) != 0 {
Expand Down Expand Up @@ -83,11 +90,26 @@ func FilteredPaginate(
end := offset + limit

var (
numHits uint64
nextKey []byte
numHits uint64
nextKey []byte
totalIter uint64
pageCompleteIter uint64
)

for ; iterator.Valid(); iterator.Next() {
totalIter++
// Phase 1: page not yet complete — cap raw iterations to prevent full-store
// walks when the filter produces too few hits to fill the page.
if countTotal && numHits < end && totalIter > end+MaxScanLimit {
return nil, status.Errorf(codes.InvalidArgument,
"scanned more than %d entries past the end of the requested page; use key-based pagination instead", MaxScanLimit)
}
// Phase 2: page complete — cap how far past the page we scan for count_total.
if countTotal && pageCompleteIter > MaxScanLimit {
return nil, status.Errorf(codes.InvalidArgument,
"scanned more than %d entries past the end of the requested page; use key-based pagination instead", MaxScanLimit)
}
Comment thread
amir-deris marked this conversation as resolved.

if iterator.Error() != nil {
return nil, iterator.Error()
}
Expand All @@ -102,6 +124,10 @@ func FilteredPaginate(
numHits++
}

if numHits >= end {
pageCompleteIter++
}

if numHits == end+1 {
nextKey = iterator.Key()

Expand Down Expand Up @@ -150,11 +176,16 @@ func GenericFilteredPaginate[T codec.ProtoMarshaler, F codec.ProtoMarshaler](
return results, nil, fmt.Errorf("invalid request, either offset or key is expected, got both")
}

if err := VerifyPaginationOffset(offset); err != nil {
return results, nil, err
}

if limit == 0 {
limit = DefaultLimit
}

// count total results when the limit is zero/not supplied
countTotal = true
if err := VerifyPaginationLimit(limit); err != nil {
return results, nil, err
}

if len(key) != 0 {
Expand Down Expand Up @@ -205,11 +236,26 @@ func GenericFilteredPaginate[T codec.ProtoMarshaler, F codec.ProtoMarshaler](
end := offset + limit

var (
numHits uint64
nextKey []byte
numHits uint64
nextKey []byte
totalIter uint64
pageCompleteIter uint64
)

for ; iterator.Valid(); iterator.Next() {
totalIter++
// Phase 1: page not yet complete — cap raw iterations to prevent full-store
// walks when the filter produces too few hits to fill the page.
if countTotal && numHits < end && totalIter > end+MaxScanLimit {
return nil, nil, status.Errorf(codes.InvalidArgument,
"scanned more than %d entries past the end of the requested page; use key-based pagination instead", MaxScanLimit)
}
// Phase 2: page complete — cap how far past the page we scan for count_total.
if countTotal && pageCompleteIter > MaxScanLimit {
return nil, nil, status.Errorf(codes.InvalidArgument,
"scanned more than %d entries past the end of the requested page; use key-based pagination instead", MaxScanLimit)
}

if iterator.Error() != nil {
return nil, nil, iterator.Error()
}
Expand All @@ -234,6 +280,10 @@ func GenericFilteredPaginate[T codec.ProtoMarshaler, F codec.ProtoMarshaler](
numHits++
}

if numHits >= end {
pageCompleteIter++
}

if numHits == end+1 {
if nextKey == nil {
nextKey = iterator.Key()
Expand Down
69 changes: 65 additions & 4 deletions sei-cosmos/types/query/filtered_pagination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func (s *paginationTestSuite) TestFilteredPaginations() {
s.Require().NoError(err)
s.Require().NotNil(res)
s.Require().Equal(4, len(balances))
s.Require().Equal(uint64(4), res.Total)
s.Require().Equal(uint64(0), res.Total)
s.Require().Nil(res.NextKey)

s.T().Log("verify nextKey is returned if there are more results")
Expand Down Expand Up @@ -79,7 +79,7 @@ func (s *paginationTestSuite) TestFilteredPaginations() {
s.Require().NoError(err)
s.Require().NotNil(res)
s.Require().Equal(4, len(balances))
s.Require().Equal(uint64(4), res.Total)
s.Require().Equal(uint64(0), res.Total)

s.T().Log("verify with offset")
pageReq = &query.PageRequest{Offset: 2, Limit: 2}
Expand Down Expand Up @@ -122,7 +122,7 @@ func (s *paginationTestSuite) TestReverseFilteredPaginations() {
s.Require().NoError(err)
s.Require().NotNil(res)
s.Require().Equal(10, len(balns))
s.Require().Equal(uint64(10), res.Total)
s.Require().Equal(uint64(0), res.Total)
s.Require().Nil(res.NextKey)

s.T().Log("verify default limit")
Expand All @@ -131,7 +131,7 @@ func (s *paginationTestSuite) TestReverseFilteredPaginations() {
s.Require().NoError(err)
s.Require().NotNil(res)
s.Require().Equal(10, len(balns))
s.Require().Equal(uint64(10), res.Total)
s.Require().Equal(uint64(0), res.Total)

s.T().Log("verify nextKey is returned if there are more results")
pageReq = &query.PageRequest{Limit: 2, CountTotal: true, Reverse: true}
Expand Down Expand Up @@ -170,6 +170,67 @@ func (s *paginationTestSuite) TestReverseFilteredPaginations() {

}

func (s *paginationTestSuite) TestFilteredPaginateMaxLimitExceeded() {
app, ctx, _ := setupTest(s.T())
store := ctx.KVStore(app.GetKey(types.StoreKey))

_, err := query.FilteredPaginate(store, &query.PageRequest{Limit: query.MaxLimit + 1}, func(_ []byte, _ []byte, _ bool) (bool, error) {
return false, nil
})
s.Require().Error(err)
s.Require().Contains(err.Error(), "exceeds maximum allowed limit")
}

func (s *paginationTestSuite) TestFilteredPaginateOffsetExceedsMax() {
app, ctx, _ := setupTest(s.T())
kvStore := ctx.KVStore(app.GetKey(types.StoreKey))

_, err := query.FilteredPaginate(kvStore, &query.PageRequest{Offset: query.MaxOffset + 1}, func(_ []byte, _ []byte, _ bool) (bool, error) {
return false, nil
})
s.Require().Error(err)
s.Require().Contains(err.Error(), "exceeds maximum allowed offset")

_, err = query.FilteredPaginate(kvStore, &query.PageRequest{Offset: query.MaxOffset}, func(_ []byte, _ []byte, _ bool) (bool, error) {
return false, nil
})
s.Require().NoError(err)
}

func (s *paginationTestSuite) TestFilteredPaginateCountTotalScanLimitExceeded() {
app, ctx, _ := setupTest(s.T())
kvStore := prefix.NewStore(ctx.KVStore(app.GetKey(types.StoreKey)), []byte("filteredscanlimit/"))

numItems := int(query.MaxScanLimit) + 2
for i := 0; i < numItems; i++ {
kvStore.Set([]byte(fmt.Sprintf("%08d", i)), []byte("v"))
}

_, err := query.FilteredPaginate(kvStore, &query.PageRequest{Limit: 1, CountTotal: true}, func(_ []byte, _ []byte, _ bool) (bool, error) {
return true, nil
})
s.Require().Error(err)
s.Require().Contains(err.Error(), "scanned more than")
}

func (s *paginationTestSuite) TestFilteredPaginateCountTotalScanLimitExceededNoHits() {
app, ctx, _ := setupTest(s.T())
kvStore := prefix.NewStore(ctx.KVStore(app.GetKey(types.StoreKey)), []byte("filteredscanlimitnohits/"))

// end = offset + limit = 0 + 1 = 1; Phase 1 fires when totalIter > end + MaxScanLimit = 10001
numItems := int(query.MaxScanLimit) + 2
for i := 0; i < numItems; i++ {
kvStore.Set([]byte(fmt.Sprintf("%08d", i)), []byte("v"))
}

// filter returns no hits — numHits never reaches end, Phase 1 guard must fire
_, err := query.FilteredPaginate(kvStore, &query.PageRequest{Limit: 1, CountTotal: true}, func(_ []byte, _ []byte, _ bool) (bool, error) {
return false, nil
})
s.Require().Error(err)
s.Require().Contains(err.Error(), "scanned more than")
}

func execFilterPaginate(store sdk.KVStore, pageReq *query.PageRequest, appCodec codec.Codec) (balances sdk.Coins, res *query.PageResponse, err error) {
balancesStore := prefix.NewStore(store, types.BalancesPrefix)
accountStore := prefix.NewStore(balancesStore, address.MustLengthPrefix(addr1))
Expand Down
74 changes: 66 additions & 8 deletions sei-cosmos/types/query/pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package query

import (
"fmt"
"math"

"github.com/sei-protocol/sei-chain/sei-cosmos/store/types"
db "github.com/tendermint/tm-db"
Expand All @@ -14,9 +13,15 @@ import (
// if the `limit` is not supplied, paginate will use `DefaultLimit`
const DefaultLimit = 100

// MaxLimit is the maximum limit the paginate function can handle
// which equals the maximum value that can be stored in uint64
const MaxLimit = math.MaxUint64
// MaxLimit is the maximum limit per page the paginate function can handle
const MaxLimit = uint64(1_000)
Comment thread
amir-deris marked this conversation as resolved.

// MaxScanLimit is the maximum number of store entries the paginate function
// will iterate past the page end when count_total is requested.
const MaxScanLimit = uint64(10_000)

// MaxOffset is the maximum offset allowed in a PageRequest.
const MaxOffset = uint64(10_000)

// ParsePagination validate PageRequest and returns page number & limit.
func ParsePagination(pageReq *PageRequest) (page, limit int, err error) {
Expand All @@ -30,27 +35,70 @@ func ParsePagination(pageReq *PageRequest) (page, limit int, err error) {
if offset < 0 {
return 1, 0, status.Error(codes.InvalidArgument, "offset must greater than 0")
}
// #nosec G115 -- offset is non-negative after validation above; fits in uint64
if offsetErr := VerifyPaginationOffset(uint64(offset)); offsetErr != nil {
return 1, 0, offsetErr
}

if limit < 0 {
return 1, 0, status.Error(codes.InvalidArgument, "limit must greater than 0")
} else if limit == 0 {
limit = DefaultLimit
}

// #nosec G115 -- limit is positive after validation above; fits in uint64
if limitErr := VerifyPaginationLimit(uint64(limit)); limitErr != nil {
return 1, 0, limitErr
}

page = offset/limit + 1

return page, limit, nil
}

func VerifyPaginationLimit(limit uint64) error {
if limit > MaxLimit {
return status.Errorf(codes.InvalidArgument, "limit %d exceeds maximum allowed limit %d", limit, MaxLimit)
}
return nil
}

func VerifyPaginationOffset(offset uint64) error {
if offset > MaxOffset {
return status.Errorf(codes.InvalidArgument, "offset %d exceeds maximum allowed offset %d", offset, MaxOffset)
}
return nil
}

// Paginate does pagination of all the results in the PrefixStore based on the
// provided PageRequest. onResult should be used to do actual unmarshaling.
// Limits are capped at MaxLimit
func Paginate(
prefixStore types.KVStore,
pageRequest *PageRequest,
onResult func(key []byte, value []byte) error,
) (*PageResponse, error) {
if pageRequest == nil {
pageRequest = &PageRequest{}
}

limit := pageRequest.Limit
if limit == 0 {
limit = DefaultLimit
}
if err := VerifyPaginationLimit(limit); err != nil {
return nil, err
}

return paginate(prefixStore, pageRequest, onResult)
}
Comment thread
amir-deris marked this conversation as resolved.

func paginate(
prefixStore types.KVStore,
pageRequest *PageRequest,
onResult func(key []byte, value []byte) error,
) (*PageResponse, error) {

// if the PageRequest is nil, use default PageRequest
if pageRequest == nil {
pageRequest = &PageRequest{}
}
Expand All @@ -65,11 +113,16 @@ func Paginate(
return nil, fmt.Errorf("invalid request, either offset or key is expected, got both")
}

if err := VerifyPaginationLimit(limit); err != nil {
return nil, err
}

if err := VerifyPaginationOffset(offset); err != nil {
return nil, err
}

if limit == 0 {
limit = DefaultLimit

// count total results when the limit is zero/not supplied
countTotal = true
}

if len(key) != 0 {
Expand Down Expand Up @@ -112,6 +165,11 @@ func Paginate(
for ; iterator.Valid(); iterator.Next() {
count++

if countTotal && count > end+MaxScanLimit {
return nil, status.Errorf(codes.InvalidArgument,
"count_total scan exceeds maximum of %d items past the page; use key-based pagination instead", MaxScanLimit)
}

if count <= offset {
continue
}
Expand Down
Loading
Loading