Skip to content

Commit 84fec64

Browse files
committed
fix: walk pending outbounds oldest-first instead of reading one page
1 parent 8e161ca commit 84fec64

2 files changed

Lines changed: 119 additions & 74 deletions

File tree

universalClient/pushcore/pushCore.go

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -395,64 +395,66 @@ func (c *Client) GetPendingFundMigrations(ctx context.Context) ([]*utsstypes.Fun
395395
}
396396

397397
// Page size and page cap for the pending-outbound walk. The cap bounds a single
398-
// poll; the remainder is picked up on the next tick.
398+
// poll; anything beyond it is picked up on the next tick.
399399
const (
400-
// AllPendingOutbounds pages by offset and returns only Total, never a NextKey,
401-
// so a key-based walk stops after one page. It also loads and sorts the whole
402-
// collection per call, so paging saves the server nothing. The sort is not
403-
// stable and orders on CreatedAt, a block height, so rows sharing a height can
404-
// change relative order between calls — which makes offset paging able to skip
405-
// a row outright. One generous request plus a Total check is the only shape
406-
// that is both correct and cheap against that server.
407-
// A row costs roughly a kilobyte on the wire, so this stays well inside gRPC's
408-
// 4 MiB default. Asking for the whole set instead would fail the call outright
409-
// once the set grew, taking the poll down rather than returning a short list.
410-
pendingOutboundLimit = 1000
400+
// A row costs roughly a kilobyte on the wire, so a page stays well inside
401+
// gRPC's 4 MiB default. Asking for the whole set in one request would fail
402+
// the call outright once the set grew, taking the poll down entirely.
403+
pendingOutboundPageSize = 1000
404+
pendingOutboundMaxPages = 5
411405

412406
chainConfigPageSize = 200
413407
chainConfigMaxPages = 20
414408
)
415409

416-
// GetAllPendingOutbounds retrieves pending outbound transactions from Push Chain.
410+
// GetAllPendingOutbounds retrieves pending outbound transactions from Push Chain,
411+
// oldest first, so older work is signed before newer.
417412
//
418-
// Read newest-first. An outbound only leaves the pending set once a quorum vote
419-
// terminalizes it, so a row that cannot reach one stays at the head of an
420-
// oldest-first list forever and would hide every newer outbound behind it — on
421-
// every chain, since this query is not chain-scoped. New outbounds always arrive
422-
// at the newest end, so reading that end cannot be starved.
413+
// Walked by offset rather than read as a single page. An outbound leaves the
414+
// pending set only when a quorum vote terminalizes it, so rows that cannot reach
415+
// one accumulate at the head of the list; without the walk they would hide every
416+
// newer outbound behind them, on every chain, since this query is not chain
417+
// scoped.
423418
//
424-
// This is discovery only and does not set signing priority. The event store
425-
// hands work to the signer ordered by block_height ASC, so older outbounds are
426-
// still signed first; reading newest-first only decides what reaches the store
427-
// to be ordered in the first place.
419+
// The walk stops at the first short page, so the ordinary case where the whole
420+
// set fits in one page costs exactly one request.
428421
func (c *Client) GetAllPendingOutbounds(ctx context.Context) ([]*uexecutortypes.PendingOutboundEntry, []*uexecutortypes.OutboundTx, error) {
429-
resp, err := retryWithRoundRobin(
430-
len(c.uexecutorClients),
431-
&c.rr,
432-
func(idx int) (*uexecutortypes.QueryAllPendingOutboundsResponse, error) {
433-
return c.uexecutorClients[idx].AllPendingOutbounds(ctx, &uexecutortypes.QueryAllPendingOutboundsRequest{
434-
Pagination: &query.PageRequest{Limit: pendingOutboundLimit, Reverse: true},
435-
})
436-
},
437-
"GetAllPendingOutbounds",
438-
c.logger,
422+
var (
423+
entries []*uexecutortypes.PendingOutboundEntry
424+
outbounds []*uexecutortypes.OutboundTx
439425
)
440-
if err != nil {
441-
return nil, nil, err
442-
}
443426

444-
// Below the limit this read is the whole set and the direction is irrelevant.
445-
// Above it, the direction is the point: anything older is already known
446-
// locally, so continuing to re-read it would achieve nothing while the newer
447-
// rows went unsigned.
448-
if resp.Pagination != nil && resp.Pagination.Total > pendingOutboundLimit {
449-
c.logger.Warn().
450-
Uint64("total", resp.Pagination.Total).
451-
Uint64("limit", pendingOutboundLimit).
452-
Msg("pending outbound set exceeds one request; only the newest are read, older rows must already be known locally")
427+
for page := 0; page < pendingOutboundMaxPages; page++ {
428+
offset := uint64(page) * pendingOutboundPageSize
429+
resp, err := retryWithRoundRobin(
430+
len(c.uexecutorClients),
431+
&c.rr,
432+
func(idx int) (*uexecutortypes.QueryAllPendingOutboundsResponse, error) {
433+
return c.uexecutorClients[idx].AllPendingOutbounds(ctx, &uexecutortypes.QueryAllPendingOutboundsRequest{
434+
Pagination: &query.PageRequest{Offset: offset, Limit: pendingOutboundPageSize},
435+
})
436+
},
437+
"GetAllPendingOutbounds",
438+
c.logger,
439+
)
440+
if err != nil {
441+
return nil, nil, err
442+
}
443+
444+
entries = append(entries, resp.Entries...)
445+
outbounds = append(outbounds, resp.Outbounds...)
446+
447+
if len(resp.Entries) < pendingOutboundPageSize {
448+
return entries, outbounds, nil
449+
}
453450
}
454451

455-
return resp.Entries, resp.Outbounds, nil
452+
c.logger.Warn().
453+
Int("max_pages", pendingOutboundMaxPages).
454+
Int("fetched", len(entries)).
455+
Msg("pending outbound page cap reached; the remainder is read on the next poll")
456+
457+
return entries, outbounds, nil
456458
}
457459

458460
// createGRPCConnection creates a gRPC connection with appropriate transport security.

universalClient/pushcore/pushCore_test.go

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"errors"
7+
"fmt"
78
"math/big"
89
"testing"
910

@@ -1048,6 +1049,11 @@ type mockUExecutorQueryClient struct {
10481049

10491050
// lastPendingReq records the request so tests can assert the limit sent.
10501051
lastPendingReq *uexecutortypes.QueryAllPendingOutboundsRequest
1052+
1053+
// pendingReqs records every page request of a walk.
1054+
pendingReqs []*uexecutortypes.QueryAllPendingOutboundsRequest
1055+
// pendingTotal, when set, makes the mock serve that many rows by offset.
1056+
pendingTotal int
10511057
}
10521058

10531059
func (m *mockUExecutorQueryClient) GasPrice(ctx context.Context, req *uexecutortypes.QueryGasPriceRequest, opts ...grpc.CallOption) (*uexecutortypes.QueryGasPriceResponse, error) {
@@ -1075,9 +1081,26 @@ func (m *mockUExecutorQueryClient) AllUniversalTx(ctx context.Context, req *uexe
10751081

10761082
func (m *mockUExecutorQueryClient) AllPendingOutbounds(ctx context.Context, req *uexecutortypes.QueryAllPendingOutboundsRequest, opts ...grpc.CallOption) (*uexecutortypes.QueryAllPendingOutboundsResponse, error) {
10771083
m.lastPendingReq = req
1084+
m.pendingReqs = append(m.pendingReqs, req)
10781085
if m.err != nil {
10791086
return nil, m.err
10801087
}
1088+
if m.pendingTotal > 0 {
1089+
offset := int(req.Pagination.GetOffset())
1090+
end := offset + int(req.Pagination.GetLimit())
1091+
if end > m.pendingTotal {
1092+
end = m.pendingTotal
1093+
}
1094+
resp := &uexecutortypes.QueryAllPendingOutboundsResponse{
1095+
Pagination: &query.PageResponse{Total: uint64(m.pendingTotal)},
1096+
}
1097+
for i := offset; i < end; i++ {
1098+
id := fmt.Sprintf("ob-%d", i)
1099+
resp.Entries = append(resp.Entries, &uexecutortypes.PendingOutboundEntry{OutboundId: id})
1100+
resp.Outbounds = append(resp.Outbounds, &uexecutortypes.OutboundTx{Id: id})
1101+
}
1102+
return resp, nil
1103+
}
10811104
return m.allPendingOutboundsResp, nil
10821105
}
10831106

@@ -1171,54 +1194,74 @@ func TestClient_GetKeyByID(t *testing.T) {
11711194
})
11721195
}
11731196

1174-
// An outbound only leaves the pending set on a quorum vote, so one that cannot
1175-
// reach a vote sits at the head of an oldest-first list permanently and hides
1176-
// everything newer. New outbounds always arrive at the newest end, so reading
1177-
// that end is what cannot be starved.
1178-
func TestClient_GetAllPendingOutbounds_ReadsNewestFirst(t *testing.T) {
1197+
// An outbound leaves the pending set only on a quorum vote, so rows that cannot
1198+
// reach one accumulate at the head of an oldest-first list. The walk is what
1199+
// stops them hiding everything newer.
1200+
func TestClient_GetAllPendingOutbounds_WalksOldestFirst(t *testing.T) {
11791201
ctx := context.Background()
11801202

1181-
resp := func(total uint64) *uexecutortypes.QueryAllPendingOutboundsResponse {
1182-
return &uexecutortypes.QueryAllPendingOutboundsResponse{
1183-
Entries: []*uexecutortypes.PendingOutboundEntry{{OutboundId: "ob-1"}},
1184-
Outbounds: []*uexecutortypes.OutboundTx{{Id: "ob-1"}},
1185-
Pagination: &query.PageResponse{Total: total},
1186-
}
1203+
newClient := func(total int) (*Client, *mockUExecutorQueryClient) {
1204+
m := &mockUExecutorQueryClient{pendingTotal: total}
1205+
return &Client{logger: zerolog.Nop(), uexecutorClients: []uexecutortypes.QueryClient{m}}, m
11871206
}
11881207

1189-
t.Run("reads the newest end, never an offset", func(t *testing.T) {
1190-
mockClient := &mockUExecutorQueryClient{allPendingOutboundsResp: resp(9)}
1191-
client := &Client{logger: zerolog.Nop(), uexecutorClients: []uexecutortypes.QueryClient{mockClient}}
1192-
1208+
t.Run("oldest first, never reversed", func(t *testing.T) {
1209+
client, m := newClient(9)
11931210
_, _, err := client.GetAllPendingOutbounds(ctx)
11941211
require.NoError(t, err)
11951212

1196-
p := mockClient.lastPendingReq.Pagination
1213+
p := m.pendingReqs[0].Pagination
11971214
require.NotNil(t, p)
1198-
assert.True(t, p.Reverse, "a stuck prefix at the oldest end must not hide newer rows")
1199-
assert.Zero(t, p.Offset, "offset zero is the only position that cannot shift under insertion")
1200-
assert.Equal(t, uint64(pendingOutboundLimit), p.Limit)
1215+
assert.False(t, p.Reverse, "older outbounds must be read first")
1216+
assert.Zero(t, p.Offset)
1217+
assert.Equal(t, uint64(pendingOutboundPageSize), p.Limit)
12011218
})
12021219

1203-
// Above the limit older rows stop being read. They are already known locally,
1204-
// but an operator should still be told the set is that large.
1205-
t.Run("reports a set larger than one request", func(t *testing.T) {
1220+
// The ordinary case is a set that fits, and it must not cost extra requests.
1221+
t.Run("a set that fits costs one request", func(t *testing.T) {
1222+
client, m := newClient(9)
1223+
entries, _, err := client.GetAllPendingOutbounds(ctx)
1224+
require.NoError(t, err)
1225+
assert.Len(t, m.pendingReqs, 1)
1226+
assert.Len(t, entries, 9)
1227+
})
1228+
1229+
// A stuck prefix must not hide what is behind it.
1230+
t.Run("walks past a full first page", func(t *testing.T) {
1231+
client, m := newClient(pendingOutboundPageSize + 250)
1232+
entries, outbounds, err := client.GetAllPendingOutbounds(ctx)
1233+
require.NoError(t, err)
1234+
1235+
require.Len(t, m.pendingReqs, 2)
1236+
assert.Equal(t, uint64(0), m.pendingReqs[0].Pagination.GetOffset())
1237+
assert.Equal(t, uint64(pendingOutboundPageSize), m.pendingReqs[1].Pagination.GetOffset())
1238+
1239+
require.Len(t, entries, pendingOutboundPageSize+250)
1240+
require.Len(t, outbounds, pendingOutboundPageSize+250)
1241+
assert.Equal(t, "ob-0", entries[0].OutboundId, "oldest first")
1242+
assert.Equal(t, fmt.Sprintf("ob-%d", pendingOutboundPageSize+249), entries[len(entries)-1].OutboundId)
1243+
})
1244+
1245+
// The cap bounds one poll; the rest is read on the next tick.
1246+
t.Run("stops at the page cap and says so", func(t *testing.T) {
12061247
var logBuf bytes.Buffer
1207-
mockClient := &mockUExecutorQueryClient{allPendingOutboundsResp: resp(pendingOutboundLimit + 500)}
1208-
client := &Client{logger: zerolog.New(&logBuf), uexecutorClients: []uexecutortypes.QueryClient{mockClient}}
1248+
m := &mockUExecutorQueryClient{pendingTotal: pendingOutboundPageSize * (pendingOutboundMaxPages + 2)}
1249+
client := &Client{logger: zerolog.New(&logBuf), uexecutorClients: []uexecutortypes.QueryClient{m}}
12091250

1210-
_, _, err := client.GetAllPendingOutbounds(ctx)
1251+
entries, _, err := client.GetAllPendingOutbounds(ctx)
12111252
require.NoError(t, err)
1212-
assert.Contains(t, logBuf.String(), "exceeds one request")
1253+
assert.Len(t, m.pendingReqs, pendingOutboundMaxPages)
1254+
assert.Len(t, entries, pendingOutboundPageSize*pendingOutboundMaxPages)
1255+
assert.Contains(t, logBuf.String(), "page cap reached")
12131256
})
12141257

12151258
t.Run("quiet when the set fits", func(t *testing.T) {
12161259
var logBuf bytes.Buffer
1217-
mockClient := &mockUExecutorQueryClient{allPendingOutboundsResp: resp(9)}
1218-
client := &Client{logger: zerolog.New(&logBuf), uexecutorClients: []uexecutortypes.QueryClient{mockClient}}
1260+
m := &mockUExecutorQueryClient{pendingTotal: 9}
1261+
client := &Client{logger: zerolog.New(&logBuf), uexecutorClients: []uexecutortypes.QueryClient{m}}
12191262

12201263
_, _, err := client.GetAllPendingOutbounds(ctx)
12211264
require.NoError(t, err)
1222-
assert.NotContains(t, logBuf.String(), "exceeds one request")
1265+
assert.NotContains(t, logBuf.String(), "page cap reached")
12231266
})
12241267
}

0 commit comments

Comments
 (0)