diff --git a/x/action/v1/keeper/query_list_actions.go b/x/action/v1/keeper/query_list_actions.go index 4e5de75c..a9601ecd 100644 --- a/x/action/v1/keeper/query_list_actions.go +++ b/x/action/v1/keeper/query_list_actions.go @@ -2,6 +2,10 @@ package keeper import ( "context" + "encoding/binary" + "fmt" + "sort" + "strconv" "github.com/LumeraProtocol/lumera/x/action/v1/types" actiontypes "github.com/LumeraProtocol/lumera/x/action/v1/types" @@ -14,6 +18,161 @@ import ( "google.golang.org/grpc/status" ) +// shouldUseNumericReverseOrdering returns true when reverse pagination should use +// numeric action ID ordering instead of lexical store-key ordering. +func shouldUseNumericReverseOrdering(pageReq *query.PageRequest) bool { + return pageReq != nil && pageReq.Reverse +} + +// parseNumericActionID parses action IDs that are strictly base-10 uint64 values. +func parseNumericActionID(actionID string) (uint64, bool) { + parsed, err := strconv.ParseUint(actionID, 10, 64) + if err != nil { + return 0, false + } + return parsed, true +} + +// sortActionsByNumericID sorts actions by ActionID using numeric ordering when +// possible, falling back to lexical ordering for non-numeric IDs. +func sortActionsByNumericID(actions []*types.Action) { + sort.SliceStable(actions, func(i, j int) bool { + leftNumericID, leftIsNumeric := parseNumericActionID(actions[i].ActionID) + rightNumericID, rightIsNumeric := parseNumericActionID(actions[j].ActionID) + + switch { + case leftIsNumeric && rightIsNumeric: + if leftNumericID == rightNumericID { + return actions[i].ActionID < actions[j].ActionID + } + return leftNumericID < rightNumericID + case leftIsNumeric != rightIsNumeric: + return leftIsNumeric + default: + return actions[i].ActionID < actions[j].ActionID + } + }) +} + +// applyNumericReverseOrderingAndPaginate applies numeric ActionID ordering in +// descending order and then paginates the resulting slice. +func applyNumericReverseOrderingAndPaginate(actions []*types.Action, pageReq *query.PageRequest) ([]*types.Action, *query.PageResponse, error) { + sortActionsByNumericID(actions) + for i, j := 0, len(actions)-1; i < j; i, j = i+1, j-1 { + actions[i], actions[j] = actions[j], actions[i] + } + + return paginateActionSlice(actions, pageReq) +} + +// collectActionsFromIDIndexStore loads actions by ID from an index store whose keys +// are action IDs. Stale index entries are ignored. +func (q queryServer) collectActionsFromIDIndexStore( + ctx sdk.Context, + indexStore prefix.Store, + actionTypeFilter types.ActionType, +) ([]*types.Action, error) { + actions := make([]*types.Action, 0) + iter := indexStore.Iterator(nil, nil) + defer iter.Close() + + for ; iter.Valid(); iter.Next() { + actionID := string(iter.Key()) + act, found := q.k.GetActionByID(ctx, actionID) + if !found { + continue + } + if actionTypeFilter != types.ActionTypeUnspecified && act.ActionType != actiontypes.ActionType(actionTypeFilter) { + continue + } + + actions = append(actions, act) + } + + return actions, nil +} + +// collectActionsFromPrimaryStore loads all actions from the primary action store. +func (q queryServer) collectActionsFromPrimaryStore(actionStore prefix.Store) ([]*types.Action, error) { + actions := make([]*types.Action, 0) + iter := actionStore.Iterator(nil, nil) + defer iter.Close() + + for ; iter.Valid(); iter.Next() { + var act actiontypes.Action + if unmarshalErr := q.k.cdc.Unmarshal(iter.Value(), &act); unmarshalErr != nil { + return nil, status.Errorf(codes.Internal, "failed to unmarshal action: %v", unmarshalErr) + } + actions = append(actions, &act) + } + + return actions, nil +} + +// decodeActionPaginationOffset decodes an opaque pagination key into an offset. +func decodeActionPaginationOffset(key []byte) (uint64, error) { + if len(key) != 8 { + return 0, fmt.Errorf("invalid key length %d", len(key)) + } + return binary.BigEndian.Uint64(key), nil +} + +// encodeActionPaginationOffset encodes an offset as an opaque pagination key. +func encodeActionPaginationOffset(offset uint64) []byte { + key := make([]byte, 8) + binary.BigEndian.PutUint64(key, offset) + return key +} + +// paginateActionSlice paginates an already materialized action slice and returns +// a PageResponse compatible with cursor- and offset-based pagination. +func paginateActionSlice(actions []*types.Action, pageReq *query.PageRequest) ([]*types.Action, *query.PageResponse, error) { + if pageReq == nil { + return actions, &query.PageResponse{}, nil + } + if pageReq.Offset > 0 && pageReq.Key != nil { + return nil, nil, status.Error(codes.InvalidArgument, "paginate: invalid request, either offset or key is expected, got both") + } + + total := uint64(len(actions)) + offset := pageReq.Offset + + if len(pageReq.Key) > 0 { + decodedOffset, err := decodeActionPaginationOffset(pageReq.Key) + if err != nil { + return nil, nil, status.Error(codes.InvalidArgument, "invalid pagination key") + } + offset = decodedOffset + } + + if offset > total { + offset = total + } + + limit := pageReq.Limit + if limit == 0 { + limit = query.DefaultLimit + } + + remaining := total - offset + if limit > remaining { + limit = remaining + } + + end := offset + limit + page := actions[int(offset):int(end)] + + pageRes := &query.PageResponse{} + if pageReq.CountTotal { + pageRes.Total = total + } + if end < total { + pageRes.NextKey = encodeActionPaginationOffset(end) + } + + return page, pageRes, nil +} + // ListActions returns a list of actions, optionally filtered by type and state func (q queryServer) ListActions(goCtx context.Context, req *types.QueryListActionsRequest) (*types.QueryListActionsResponse, error) { if req == nil { @@ -37,65 +196,101 @@ func (q queryServer) ListActions(goCtx context.Context, req *types.QueryListActi statePrefix := []byte(ActionByStatePrefix + types.ActionState(req.ActionState).String() + "/") indexStore := prefix.NewStore(storeAdapter, statePrefix) - onResult := func(key, _ []byte, accumulate bool) (bool, error) { - actionID := string(key) - act, found := q.k.GetActionByID(ctx, actionID) - if !found { - // Stale index entry; skip without counting - return false, nil + if shouldUseNumericReverseOrdering(req.Pagination) { + // Numeric reverse ordering cannot be derived from lexical KV iteration, so + // we materialize the matched set, sort it numerically, then paginate. + actions, err = q.collectActionsFromIDIndexStore(ctx, indexStore, req.ActionType) + if err != nil { + return nil, err } - if req.ActionType != types.ActionTypeUnspecified && act.ActionType != actiontypes.ActionType(req.ActionType) { - return false, nil - } + actions, pageRes, err = applyNumericReverseOrderingAndPaginate(actions, req.Pagination) + } else { + onResult := func(key, _ []byte, accumulate bool) (bool, error) { + actionID := string(key) + act, found := q.k.GetActionByID(ctx, actionID) + if !found { + // Stale index entry; skip without counting + return false, nil + } + + if req.ActionType != types.ActionTypeUnspecified && act.ActionType != actiontypes.ActionType(req.ActionType) { + return false, nil + } + + if accumulate { + actions = append(actions, act) + } - if accumulate { - actions = append(actions, act) + return true, nil } - return true, nil + pageRes, err = query.FilteredPaginate(indexStore, req.Pagination, onResult) } - - pageRes, err = query.FilteredPaginate(indexStore, req.Pagination, onResult) } else if useTypeIndex { // When filtering only by type, use the type index typePrefix := []byte(ActionByTypePrefix + types.ActionType(req.ActionType).String() + "/") indexStore := prefix.NewStore(storeAdapter, typePrefix) - onResult := func(key, _ []byte, accumulate bool) (bool, error) { - actionID := string(key) - act, found := q.k.GetActionByID(ctx, actionID) - if !found { - // Stale index entry; skip - return false, nil + if shouldUseNumericReverseOrdering(req.Pagination) { + // Numeric reverse ordering cannot be derived from lexical KV iteration, so + // we materialize the matched set, sort it numerically, then paginate. + actions, err = q.collectActionsFromIDIndexStore(ctx, indexStore, types.ActionTypeUnspecified) + if err != nil { + return nil, err } - if accumulate { - actions = append(actions, act) + actions, pageRes, err = applyNumericReverseOrderingAndPaginate(actions, req.Pagination) + } else { + onResult := func(key, _ []byte, accumulate bool) (bool, error) { + actionID := string(key) + act, found := q.k.GetActionByID(ctx, actionID) + if !found { + // Stale index entry; skip + return false, nil + } + + if accumulate { + actions = append(actions, act) + } + + return true, nil } - return true, nil + pageRes, err = query.FilteredPaginate(indexStore, req.Pagination, onResult) } - - pageRes, err = query.FilteredPaginate(indexStore, req.Pagination, onResult) } else { actionStore := prefix.NewStore(storeAdapter, []byte(ActionKeyPrefix)) - onResult := func(key, value []byte, accumulate bool) (bool, error) { - var act actiontypes.Action - if err := q.k.cdc.Unmarshal(value, &act); err != nil { - return false, err + if shouldUseNumericReverseOrdering(req.Pagination) { + // Numeric reverse ordering cannot be derived from lexical KV iteration, so + // we materialize the matched set, sort it numerically, then paginate. + actions, err = q.collectActionsFromPrimaryStore(actionStore) + if err != nil { + return nil, err } - if accumulate { - actions = append(actions, &act) - } + actions, pageRes, err = applyNumericReverseOrderingAndPaginate(actions, req.Pagination) + } else { + onResult := func(key, value []byte, accumulate bool) (bool, error) { + var act actiontypes.Action + if err := q.k.cdc.Unmarshal(value, &act); err != nil { + return false, err + } + + if accumulate { + actions = append(actions, &act) + } - return true, nil + return true, nil + } + pageRes, err = query.FilteredPaginate(actionStore, req.Pagination, onResult) } - pageRes, err = query.FilteredPaginate(actionStore, req.Pagination, onResult) } if err != nil { + if st, ok := status.FromError(err); ok && st.Code() != codes.Unknown { + return nil, err + } return nil, status.Errorf(codes.Internal, "failed to paginate actions: %v", err) } diff --git a/x/action/v1/keeper/query_list_actions_test.go b/x/action/v1/keeper/query_list_actions_test.go index c92f28fc..f0400432 100644 --- a/x/action/v1/keeper/query_list_actions_test.go +++ b/x/action/v1/keeper/query_list_actions_test.go @@ -1,6 +1,7 @@ package keeper_test import ( + "fmt" "testing" keepertest "github.com/LumeraProtocol/lumera/testutil/keeper" @@ -172,3 +173,232 @@ func TestKeeper_ListActions(t *testing.T) { }) } } + +func TestKeeper_ListActions_ReversePaginationUsesNumericActionIDOrder(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + k, ctx := keepertest.ActionKeeper(t, ctrl) + q := keeper.NewQueryServerImpl(k) + price := sdk.NewInt64Coin("stake", 100) + + // Reproduces the mainnet boundary where lexical ordering would place 99999 before 123611. + actionLowLexical := types.Action{ + Creator: "creator-low", + ActionID: "99999", + ActionType: types.ActionTypeCascade, + Metadata: []byte("metadata-low"), + Price: price.String(), + ExpirationTime: 1234567891, + State: types.ActionStateApproved, + BlockHeight: 100, + SuperNodes: []string{"supernode-1"}, + } + actionHighNumeric := types.Action{ + Creator: "creator-high", + ActionID: "123611", + ActionType: types.ActionTypeCascade, + Metadata: []byte("metadata-high"), + Price: price.String(), + ExpirationTime: 1234567892, + State: types.ActionStateApproved, + BlockHeight: 101, + SuperNodes: []string{"supernode-2"}, + } + + require.NoError(t, k.SetAction(ctx, &actionLowLexical)) + require.NoError(t, k.SetAction(ctx, &actionHighNumeric)) + + resp, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + Pagination: &query.PageRequest{ + Limit: 1, + Reverse: true, + }, + }) + require.NoError(t, err) + require.Len(t, resp.Actions, 1) + require.Equal(t, "123611", resp.Actions[0].ActionID) +} + +func TestKeeper_ListActions_ReversePaginationCursorMaintainsNumericOrder(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + k, ctx := keepertest.ActionKeeper(t, ctrl) + q := keeper.NewQueryServerImpl(k) + price := sdk.NewInt64Coin("stake", 100) + + ids := []string{"2", "10", "100"} + for i, id := range ids { + action := types.Action{ + Creator: "creator-" + id, + ActionID: id, + ActionType: types.ActionTypeCascade, + Metadata: []byte("metadata-" + id), + Price: price.String(), + ExpirationTime: int64(1234567890 + i), + State: types.ActionStateApproved, + BlockHeight: int64(100 + i), + SuperNodes: []string{"supernode-" + id}, + } + require.NoError(t, k.SetAction(ctx, &action)) + } + + firstPage, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + Pagination: &query.PageRequest{ + Limit: 1, + Reverse: true, + }, + }) + require.NoError(t, err) + require.Len(t, firstPage.Actions, 1) + require.Equal(t, "100", firstPage.Actions[0].ActionID) + require.NotEmpty(t, firstPage.Pagination.NextKey) + + secondPage, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + Pagination: &query.PageRequest{ + Key: firstPage.Pagination.NextKey, + Limit: 1, + Reverse: true, + }, + }) + require.NoError(t, err) + require.Len(t, secondPage.Actions, 1) + require.Equal(t, "10", secondPage.Actions[0].ActionID) +} + +func TestKeeper_ListActions_ReversePaginationWithTypeFilterUsesNumericOrder(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + k, ctx := keepertest.ActionKeeper(t, ctrl) + q := keeper.NewQueryServerImpl(k) + price := sdk.NewInt64Coin("stake", 100) + + actionLowLexical := types.Action{ + Creator: "creator-low", + ActionID: "99999", + ActionType: types.ActionTypeCascade, + Metadata: []byte("metadata-low"), + Price: price.String(), + ExpirationTime: 1234567891, + State: types.ActionStateApproved, + BlockHeight: 100, + SuperNodes: []string{"supernode-1"}, + } + actionHighNumeric := types.Action{ + Creator: "creator-high", + ActionID: "123611", + ActionType: types.ActionTypeCascade, + Metadata: []byte("metadata-high"), + Price: price.String(), + ExpirationTime: 1234567892, + State: types.ActionStateApproved, + BlockHeight: 101, + SuperNodes: []string{"supernode-2"}, + } + actionDifferentType := types.Action{ + Creator: "creator-other-type", + ActionID: "999999", + ActionType: types.ActionTypeSense, + Metadata: []byte("metadata-other-type"), + Price: price.String(), + ExpirationTime: 1234567893, + State: types.ActionStateApproved, + BlockHeight: 102, + SuperNodes: []string{"supernode-3"}, + } + + require.NoError(t, k.SetAction(ctx, &actionLowLexical)) + require.NoError(t, k.SetAction(ctx, &actionHighNumeric)) + require.NoError(t, k.SetAction(ctx, &actionDifferentType)) + + resp, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + ActionType: types.ActionTypeCascade, + Pagination: &query.PageRequest{ + Limit: 1, + Reverse: true, + }, + }) + require.NoError(t, err) + require.Len(t, resp.Actions, 1) + require.Equal(t, "123611", resp.Actions[0].ActionID) +} + +func TestKeeper_ListActions_ReversePaginationInvalidCursorKey(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + k, ctx := keepertest.ActionKeeper(t, ctrl) + q := keeper.NewQueryServerImpl(k) + + resp, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + Pagination: &query.PageRequest{ + Key: []byte("bad-key"), + Reverse: true, + }, + }) + require.Nil(t, resp) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.InvalidArgument, st.Code()) +} + +func TestKeeper_ListActions_ReversePaginationRejectsOffsetAndKey(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + k, ctx := keepertest.ActionKeeper(t, ctrl) + q := keeper.NewQueryServerImpl(k) + + resp, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + Pagination: &query.PageRequest{ + Key: []byte{0, 0, 0, 0, 0, 0, 0, 1}, + Offset: 1, + Reverse: true, + }, + }) + require.Nil(t, resp) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.InvalidArgument, st.Code()) + require.Contains(t, st.Message(), "either offset or key is expected, got both") +} + +func TestKeeper_ListActions_ReversePaginationZeroLimitUsesDefaultLimit(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + k, ctx := keepertest.ActionKeeper(t, ctrl) + q := keeper.NewQueryServerImpl(k) + price := sdk.NewInt64Coin("stake", 100) + + entries := query.DefaultLimit + 1 + for i := 1; i <= entries; i++ { + id := fmt.Sprintf("%d", i) + action := types.Action{ + Creator: "creator-" + id, + ActionID: id, + ActionType: types.ActionTypeCascade, + Metadata: []byte("metadata-" + id), + Price: price.String(), + ExpirationTime: int64(1234567890 + i), + State: types.ActionStateApproved, + BlockHeight: int64(200 + i), + SuperNodes: []string{"supernode-" + id}, + } + require.NoError(t, k.SetAction(ctx, &action)) + } + + resp, err := q.ListActions(ctx, &types.QueryListActionsRequest{ + Pagination: &query.PageRequest{ + Limit: 0, + Reverse: true, + }, + }) + require.NoError(t, err) + require.Len(t, resp.Actions, int(query.DefaultLimit)) + require.NotEmpty(t, resp.Pagination.NextKey) +}