Skip to content

Commit 19c3b6f

Browse files
committed
fix: carry pending outbound cursor between polls so the page budget costs latency not coverage (F-2026-18817)
1 parent 8ca34bd commit 19c3b6f

2 files changed

Lines changed: 78 additions & 3 deletions

File tree

universalClient/pushcore/pushCore.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"math/big"
1111
"strings"
12+
"sync"
1213
"sync/atomic"
1314

1415
cmtservice "github.com/cosmos/cosmos-sdk/client/grpc/cmtservice"
@@ -40,6 +41,13 @@ type Client struct {
4041
authClients []authtypes.QueryClient // Auth query clients
4142
conns []*grpc.ClientConn // Owned gRPC connections (for cleanup)
4243
rr uint32 // Round-robin counter for endpoint selection
44+
45+
// Pagination cursor carried between pending-outbound polls. The page budget
46+
// bounds one poll's work; this makes the budget cost latency rather than
47+
// coverage, so a set larger than the budget is still walked in full over
48+
// successive ticks. Nil means start from the beginning.
49+
pendingMu sync.Mutex
50+
pendingCursor []byte
4351
}
4452

4553
// New creates a new Client by dialing the provided gRPC URLs.
@@ -414,10 +422,13 @@ const (
414422
// would let such a prefix hide every newer outbound from signing, on every
415423
// chain, since this query is not chain-scoped.
416424
func (c *Client) GetAllPendingOutbounds(ctx context.Context) ([]*uexecutortypes.PendingOutboundEntry, []*uexecutortypes.OutboundTx, error) {
425+
c.pendingMu.Lock()
426+
defer c.pendingMu.Unlock()
427+
417428
var (
418429
entries []*uexecutortypes.PendingOutboundEntry
419430
outbounds []*uexecutortypes.OutboundTx
420-
nextKey []byte
431+
nextKey = c.pendingCursor
421432
)
422433

423434
for page := 0; page < pendingOutboundMaxPages; page++ {
@@ -435,27 +446,36 @@ func (c *Client) GetAllPendingOutbounds(ctx context.Context) ([]*uexecutortypes.
435446
)
436447
if err != nil {
437448
// Return what we have rather than nothing: a later page failing must
438-
// not stop the caller acting on the pages that did arrive.
449+
// not stop the caller acting on the pages that did arrive. Resume from
450+
// the failed page next time instead of losing the ground already made.
439451
if len(entries) > 0 {
452+
c.pendingCursor = key
440453
c.logger.Warn().Err(err).Int("page", page).Msg("pending outbound page failed, using pages fetched so far")
441454
return entries, outbounds, nil
442455
}
456+
c.pendingCursor = nil
443457
return nil, nil, err
444458
}
445459

446460
entries = append(entries, resp.Entries...)
447461
outbounds = append(outbounds, resp.Outbounds...)
448462

449463
if resp.Pagination == nil || len(resp.Pagination.NextKey) == 0 {
464+
// Reached the end; the next poll starts from the beginning again so
465+
// rows added at the tail since the walk began are picked up.
466+
c.pendingCursor = nil
450467
return entries, outbounds, nil
451468
}
452469
nextKey = resp.Pagination.NextKey
453470
}
454471

472+
// Budget spent mid-set. Park the cursor so the next tick continues from here
473+
// rather than re-reading the same prefix forever.
474+
c.pendingCursor = nextKey
455475
c.logger.Warn().
456476
Int("max_pages", pendingOutboundMaxPages).
457477
Int("fetched", len(entries)).
458-
Msg("pending outbound page cap reached; remainder deferred to next poll")
478+
Msg("pending outbound page budget spent; continuing from this cursor next poll")
459479
return entries, outbounds, nil
460480
}
461481

universalClient/pushcore/pushCore_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,3 +1347,58 @@ func TestClient_GetAllChainConfigs_Paginates(t *testing.T) {
13471347
assert.Nil(t, configs)
13481348
})
13491349
}
1350+
1351+
// The page budget bounds one poll's work, so it must cost latency rather than
1352+
// coverage: a set larger than the budget has to be walked in full across
1353+
// successive polls instead of re-reading the same prefix forever.
1354+
func TestClient_GetAllPendingOutbounds_CarriesCursorAcrossPolls(t *testing.T) {
1355+
ctx := context.Background()
1356+
1357+
total := pendingOutboundMaxPages + 3
1358+
pages := make([]*uexecutortypes.QueryAllPendingOutboundsResponse, total)
1359+
for i := range pages {
1360+
var next []byte
1361+
if i < total-1 {
1362+
next = []byte(fmt.Sprintf("k%d", i+1))
1363+
}
1364+
pages[i] = pendingPage(fmt.Sprintf("ob-%d", i), next)
1365+
}
1366+
mockClient := &mockUExecutorQueryClient{pages: pages}
1367+
client := &Client{logger: zerolog.Nop(), uexecutorClients: []uexecutortypes.QueryClient{mockClient}}
1368+
1369+
// First poll spends the budget and parks mid-set.
1370+
first, _, err := client.GetAllPendingOutbounds(ctx)
1371+
require.NoError(t, err)
1372+
require.Len(t, first, pendingOutboundMaxPages)
1373+
require.NotNil(t, client.pendingCursor, "must remember where it stopped")
1374+
1375+
// Second poll resumes from there rather than restarting at the head.
1376+
resumeFrom := client.pendingCursor
1377+
second, _, err := client.GetAllPendingOutbounds(ctx)
1378+
require.NoError(t, err)
1379+
require.Len(t, second, 3, "the remainder of the set")
1380+
assert.Equal(t, "ob-"+fmt.Sprint(pendingOutboundMaxPages), second[0].OutboundId,
1381+
"must continue after the parked cursor, not re-read the prefix")
1382+
assert.Equal(t, resumeFrom, mockClient.requestedKeys[pendingOutboundMaxPages],
1383+
"the parked cursor is what gets sent")
1384+
1385+
// Reaching the end resets, so tail additions are seen on the next poll.
1386+
assert.Nil(t, client.pendingCursor)
1387+
}
1388+
1389+
// A failed page must not lose the ground already covered either.
1390+
func TestClient_GetAllPendingOutbounds_ResumesAfterPageFailure(t *testing.T) {
1391+
mockClient := &mockUExecutorQueryClient{
1392+
pages: []*uexecutortypes.QueryAllPendingOutboundsResponse{
1393+
pendingPage("ob-0", []byte("k1")),
1394+
pendingPage("ob-1", []byte("k2")),
1395+
},
1396+
failAfterPage: 1,
1397+
}
1398+
client := &Client{logger: zerolog.Nop(), uexecutorClients: []uexecutortypes.QueryClient{mockClient}}
1399+
1400+
entries, _, err := client.GetAllPendingOutbounds(context.Background())
1401+
require.NoError(t, err)
1402+
require.Len(t, entries, 1)
1403+
assert.Equal(t, []byte("k1"), client.pendingCursor, "resume at the page that failed")
1404+
}

0 commit comments

Comments
 (0)