wallet: implement store pagination#1189
wallet: implement store pagination#1189GustavoStingelin wants to merge 4 commits intobtcsuite:sql-walletfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces cursor-based pagination for database “list” queries (wallets, accounts, addresses) and adds iterator-style APIs (Iter*) to transparently traverse all pages via iter.Seq2, preventing large result sets from being loaded at once.
Changes:
- Add a
pageutility package (Options,Iter) and shared paging helpers to implement cursor-based pagination consistently. - Update wallet/account/address store interfaces and implementations to return paged results by default and provide
Iter*helpers for full traversal. - Regenerate sqlc queries (first-page/next-page) for SQLite and Postgres, and expand integration tests to cover paging + iterators.
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| wallet/internal/db/wallets_common.go | Shared wallet pagination helpers (cursor advancement, row conversion). |
| wallet/internal/db/wallet_sqlite.go | SQLite wallet listing now paginated; adds IterWallets. |
| wallet/internal/db/wallet_pg.go | Postgres wallet listing now paginated; adds IterWallets. |
| wallet/internal/db/sqlc/sqlite/wallets.sql.go | sqlc-generated SQLite wallet queries split into first/next page. |
| wallet/internal/db/sqlc/sqlite/querier.go | Updates SQLite sqlc Querier interface for paged list methods. |
| wallet/internal/db/sqlc/sqlite/db.go | Prepares/closes new SQLite prepared statements for paged queries. |
| wallet/internal/db/sqlc/sqlite/addresses.sql.go | sqlc-generated SQLite address list split into first/next page. |
| wallet/internal/db/sqlc/sqlite/accounts.sql.go | sqlc-generated SQLite account lists split into first/next page variants. |
| wallet/internal/db/sqlc/postgres/wallets.sql.go | sqlc-generated Postgres wallet queries split into first/next page. |
| wallet/internal/db/sqlc/postgres/querier.go | Updates Postgres sqlc Querier interface for paged list methods. |
| wallet/internal/db/sqlc/postgres/db.go | Prepares/closes new Postgres prepared statements for paged queries. |
| wallet/internal/db/sqlc/postgres/addresses.sql.go | sqlc-generated Postgres address list split into first/next page. |
| wallet/internal/db/sqlc/postgres/accounts.sql.go | sqlc-generated Postgres account lists split into first/next page variants. |
| wallet/internal/db/queries/sqlite/wallets.sql | Source SQL for SQLite wallet paging queries. |
| wallet/internal/db/queries/sqlite/addresses.sql | Source SQL for SQLite address paging queries. |
| wallet/internal/db/queries/sqlite/accounts.sql | Source SQL for SQLite account paging queries. |
| wallet/internal/db/queries/postgres/wallets.sql | Source SQL for Postgres wallet paging queries. |
| wallet/internal/db/queries/postgres/addresses.sql | Source SQL for Postgres address paging queries. |
| wallet/internal/db/queries/postgres/accounts.sql | Source SQL for Postgres account paging queries. |
| wallet/internal/db/pagination_common.go | Shared generic helper to fetch either first or next page by cursor. |
| wallet/internal/db/page/options_test.go | Unit tests for pagination options normalization/immutability. |
| wallet/internal/db/page/options.go | Pagination Options type (size/cursor, normalization). |
| wallet/internal/db/page/iter_test.go | Unit tests for iterator paging traversal, errors, cancellation, break. |
| wallet/internal/db/page/iter.go | Core paging iterator (Iter) producing iter.Seq2. |
| wallet/internal/db/page/doc.go | Package documentation for page. |
| wallet/internal/db/itest/wallet_store_test.go | Adds wallet paging + iterator integration tests. |
| wallet/internal/db/itest/fixtures_common_test.go | Adds shared-name fixtures and helpers used by pagination tests. |
| wallet/internal/db/itest/address_store_test.go | Adds address paging + iterator integration tests. |
| wallet/internal/db/itest/account_store_test.go | Adds account paging + iterator integration tests and cursor helpers. |
| wallet/internal/db/interface.go | Store interfaces updated: paged List* and new Iter* methods. |
| wallet/internal/db/data_types.go | Adds list query types + pagination cursor types (wallets/accounts/addresses). |
| wallet/internal/db/addresses_sqlite.go | SQLite address listing now paginated; adds IterAddresses. |
| wallet/internal/db/addresses_pg.go | Postgres address listing now paginated; adds IterAddresses. |
| wallet/internal/db/addresses_common.go | Shared address paging helpers (cursor advancement + paging adapter). |
| wallet/internal/db/accounts_sqlite.go | SQLite account listing now paginated; adds IterAccounts. |
| wallet/internal/db/accounts_pg.go | Postgres account listing now paginated; adds IterAccounts. |
| wallet/internal/db/accounts_common.go | Shared account paging helpers (cursor advancement + stable tie-breaker). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
f63815f to
802ca17
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 37 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
wallet/internal/db/interface.go
Outdated
| ListAccounts(ctx context.Context, query ListAccountsQuery) ( | ||
| []AccountInfo, error) | ||
|
|
||
| // IterAccounts returns an iterator that fetches pages transparently and | ||
| // yields accounts one by one until exhaustion or error. | ||
| IterAccounts(ctx context.Context, | ||
| query ListAccountsQuery) iter.Seq2[AccountInfo, error] |
There was a problem hiding this comment.
I am thinking that, since we will always rely on the automatic pagination provided by the iterator, it may be better to drop the list method entirely, or change its signature to return iter.Seq2.
This would expose a single way to query these results and help prevent misuse.
802ca17 to
50ab4fc
Compare
yyforyongyu
left a comment
There was a problem hiding this comment.
Hmm I think the new API exposes too many details to the caller, so ask a few LLMs on how to improve it plus a few of my own thoughts, I think the design can be improved.
|
|
||
| var errTest = errors.New("test error") | ||
|
|
||
| func TestIterTraversal(t *testing.T) { |
There was a problem hiding this comment.
None of these tests have docs?
| -- in a multi-table JOIN context. | ||
| AND ( | ||
| ( | ||
| NOT sqlc.arg(cursor_imported)::BOOL -- noqa: RF02 |
There was a problem hiding this comment.
Add some inline docs to explain the noqa?
wallet/internal/db/addresses_pg.go
Outdated
| ) | ||
| } | ||
|
|
||
| func pgBuildFirstPageParams(q ListAddressesQuery) func( |
There was a problem hiding this comment.
it seems to me that a lot of the new methods are not documented, maybe we could point out the docs @docs/developer/ENGINEERING_GUIDE.md and @docs/developer/contribution_guidelines.md so agents will follow.
wallet/internal/db/page/iter.go
Outdated
| return func(yield func(R, error) bool) { | ||
| for { | ||
| select { | ||
| case <-ctx.Done(): |
There was a problem hiding this comment.
This check is redundant - I have also seen it multiple times that LLMs turn to have this pattern, weird...but fetchPage below will already handle it if the context is done. Plus once we are in the for _, item := range items { loop, the cancellation won't stop the yield emission.
wallet/internal/db/page/iter.go
Outdated
|
|
||
| // Iter iterates through paginated results by repeatedly fetching pages | ||
| // and computing the next query from the last returned item. | ||
| func Iter[Q, R any](ctx context.Context, query Q, |
There was a problem hiding this comment.
I think the current naming is a bit hard to follow, so suggest sth like,
func Iter[Query, Item any](
ctx context.Context,
initial Query,
fetchPage func(context.Context, Query) ([]Item, error),
nextQuery func(Query, []Item) Query,
) iter.Seq2[Item, error]| } | ||
|
|
||
| for _, item := range items { | ||
| if !yield(item, nil) { |
There was a problem hiding this comment.
I guess if we wanna catch the cancel signal, we need to perform it here.
wallet/internal/db/page/options.go
Outdated
|
|
||
| // Cursor is the pagination cursor from the previous page. | ||
| // Nil means the first page. | ||
| Cursor *C |
There was a problem hiding this comment.
Does it mean this is mutable?
wallet/internal/db/page/options.go
Outdated
|
|
||
| // WithCursor returns a copy of options with cursor replaced. | ||
| func (o Options[C]) WithCursor(cursor C) Options[C] { | ||
| o.Cursor = &cursor |
There was a problem hiding this comment.
to keep it immutable, i think sth like,
c := cursor
o.cursor = &cThis is not a deep copy, but it should prevent direct mutation through opts.Cursor.
| } | ||
| } | ||
|
|
||
| func TestOptionsWithSizeImmutability(t *testing.T) { |
There was a problem hiding this comment.
Good that we have these immutability tests, but it seems like we only test against uint32 while we support Options[C any]. I think we need to add more tests.
There was a problem hiding this comment.
That said, I think it's better to keep the cursor unexported - maybe add a new method Cursor and return the copy for reading?
| require.Equal(t, accountA[:2], items1) | ||
|
|
||
| query.Page = query.Page.WithCursor(nextAddressCursor(t, items1)) | ||
| items2, err := store.ListAddresses(t.Context(), query) |
There was a problem hiding this comment.
If we adopt the new design, the call will instead be,
res, err := store.ListAccounts(ctx, q)
if err != nil { ... }
for _, acc := range res.Items {
...
}
if res.Next != nil {
q.Page.After = res.Next
}|
Yes — here’s the redesign I’d recommend. The short version:
Goals The redesigned API should make these things true:
What’s wrong with the current shape The current design has a few structural problems:
The main design mistake is ownership: the store knows pagination semantics, but the caller is currently forced to participate in them. Proposed API I would replace package page
const (
DefaultLimit = 100
MaxLimit = 1000
)
type Request[C any] struct {
Limit uint32
After *C
}
func (r Request[C]) EffectiveLimit() uint32 {
switch {
case r.Limit == 0:
return DefaultLimit
case r.Limit > MaxLimit:
return MaxLimit
default:
return r.Limit
}
}
type Result[T any, C any] struct {
Items []T
Next *C
}This is a much better fit:
Query types Then the store query types become simple and obvious. type ListWalletsQuery struct {
Page page.Request[uint32]
}
type AccountCursor struct {
Imported bool
AccountNumber uint32
RowID uint32
}
type ListAccountsQuery struct {
WalletID uint32
Scope *KeyScope
Name *string
Page page.Request[AccountCursor]
}
type ListAddressesQuery struct {
WalletID uint32
Scope KeyScope
AccountName string
Page page.Request[uint32]
}This reads better than: page.Options[uint32]{}.WithSize(50).WithCursor(99)and it is easier to debug because you can just print the struct. Store interfaces The store interfaces should return type WalletStore interface {
ListWallets(ctx context.Context, query ListWalletsQuery) (
page.Result[WalletInfo, uint32], error,
)
IterWallets(ctx context.Context, query ListWalletsQuery) iter.Seq2[WalletInfo, error]
}
type AccountStore interface {
ListAccounts(ctx context.Context, query ListAccountsQuery) (
page.Result[AccountInfo, AccountCursor], error,
)
IterAccounts(ctx context.Context, query ListAccountsQuery) iter.Seq2[AccountInfo, error]
}
type AddressStore interface {
ListAddresses(ctx context.Context, query ListAddressesQuery) (
page.Result[AddressInfo, uint32], error,
)
IterAddresses(ctx context.Context, query ListAddressesQuery) iter.Seq2[AddressInfo, error]
}This is the key public API improvement. Caller experience Current style: items, err := store.ListAccounts(ctx, q)
if err != nil {
return err
}
for _, item := range items {
use(item)
}
if len(items) > 0 {
q.Page = q.Page.WithCursor(items[len(items)-1].Cursor())
}Redesigned style: res, err := store.ListAccounts(ctx, q)
if err != nil {
return err
}
for _, item := range res.Items {
use(item)
}
if res.Next != nil {
q.Page.After = res.Next
}That is much easier to use, and much harder to misuse. The store computes Why This removes pagination knowledge from item types. Right now something like
That logic should not live in the public With func accountCursorFromInfo(info AccountInfo) AccountCursor {
return AccountCursor{
Imported: info.Origin == ImportedAccount,
AccountNumber: info.AccountNumber,
RowID: info.ID,
}
}That helper stays private to the store package. Store implementation pattern For each
That removes the need for generic Example: This is the ideal simple case because the cursor is just func (s *SqliteStore) ListWallets(
ctx context.Context,
query ListWalletsQuery,
) (page.Result[WalletInfo, uint32], error) {
limit := query.Page.EffectiveLimit()
fetchLimit := int64(limit) + 1
rows, err := s.queries.ListWallets(ctx, sqlc.ListWalletsParams{
AfterID: nullUint32(query.Page.After),
Limit: fetchLimit,
})
if err != nil {
return page.Result[WalletInfo, uint32]{}, fmt.Errorf("list wallets: %w", err)
}
items, err := walletInfosFromRows(rows)
if err != nil {
return page.Result[WalletInfo, uint32]{}, fmt.Errorf("convert wallets: %w", err)
}
var next *uint32
n := int(limit)
if len(items) > n {
cursor := items[n-1].ID
next = &cursor
items = items[:n]
}
return page.Result[WalletInfo, uint32]{
Items: items,
Next: next,
}, nil
}The important detail is this line: cursor := items[n-1].IDWhen you fetch Example: With the new func (s *SqliteStore) IterWallets(
ctx context.Context,
query ListWalletsQuery,
) iter.Seq2[WalletInfo, error] {
return func(yield func(WalletInfo, error) bool) {
for {
res, err := s.ListWallets(ctx, query)
if err != nil {
var zero WalletInfo
_ = yield(zero, err)
return
}
for _, item := range res.Items {
select {
case <-ctx.Done():
var zero WalletInfo
_ = yield(zero, ctx.Err())
return
default:
}
if !yield(item, nil) {
return
}
}
if res.Next == nil {
return
}
next := *res.Next
query.Page.After = &next
}
}
}I would handwrite If you still want a generic helper, it should operate on A better generic iterator, if you keep one If you really want a helper, make it work with explicit page metadata. func Iter[Q, T, C any](
ctx context.Context,
query Q,
fetch func(context.Context, Q) (page.Result[T, C], error),
withAfter func(Q, *C) Q,
) iter.Seq2[T, error] {
return func(yield func(T, error) bool) {
for {
res, err := fetch(ctx, query)
if err != nil {
var zero T
_ = yield(zero, err)
return
}
for _, item := range res.Items {
select {
case <-ctx.Done():
var zero T
_ = yield(zero, ctx.Err())
return
default:
}
if !yield(item, nil) {
return
}
}
if res.Next == nil {
return
}
query = withAfter(query, res.Next)
}
}
}That is much better than the current But again, I would still prefer handwritten iterators here. SQL design There are two parts here:
The API redesign should happen regardless of whether all SQL is unified. Wallets and addresses: use one query These are the easy cases. Use one query with a nullable cursor and Pseudo-SQL: SELECT ...
FROM wallets w
WHERE (:after_id IS NULL OR w.id > :after_id)
ORDER BY w.id
LIMIT :limit_plus_one;and similarly for addresses: SELECT ...
FROM addresses a
WHERE
a.account_id = :account_id
AND (:after_id IS NULL OR a.id > :after_id)
ORDER BY a.id
LIMIT :limit_plus_one;This gives you:
Accounts: two acceptable options Accounts are more complicated because the sort key is composite:
You have two reasonable choices. Option A: one unified query Use a single query with nullable cursor fields. Pseudo-SQL: SELECT ...
FROM accounts a
WHERE
a.wallet_id = :wallet_id
AND (:scope_purpose IS NULL OR a.purpose = :scope_purpose)
AND (:scope_coin_type IS NULL OR a.coin_type = :scope_coin_type)
AND (:name IS NULL OR a.account_name = :name)
AND (
:after_id IS NULL
OR (
(
NOT :after_imported
AND a.account_number IS NOT NULL
AND (
a.account_number > :after_account_number
OR (
a.account_number = :after_account_number
AND a.id > :after_id
)
)
)
OR (
a.account_number IS NULL
AND (
NOT :after_imported
OR a.id > :after_id
)
)
)
)
ORDER BY a.account_number NULLS LAST, a.id
LIMIT :limit_plus_one;That is still complicated SQL, but the complexity is in one place instead of duplicated across first-page and next-page versions. This is the cleanest overall design if the query planner behaves well. Option B: keep split SQL for accounts only If That is fine. The important point is: the Go API does not depend on SQL unification. You can still ship the better That is probably the safest rollout path. Query planner caution This is the one place where I would be pragmatic instead of ideological. For wallets and addresses, one nullable-cursor query is obviously worth it. For accounts, I would require plan verification:
If the unified account query regresses, do not force it. Keep two queries there. Optional tiny helper for If the trimming logic repeats too much, add one very small helper. func BuildResult[T any, C any](
items []T,
limit uint32,
cursorOf func(T) C,
) page.Result[T, C] {
n := int(limit)
if len(items) <= n {
return page.Result[T, C]{Items: items}
}
cursor := cursorOf(items[n-1])
return page.Result[T, C]{
Items: items[:n],
Next: &cursor,
}
}That is the kind of generic helper that helps without taking over the design. This is very different from the current multi-layer generic abstraction. What the API should not do I would explicitly avoid these patterns:
Testing strategy Once redesigned, the tests should validate the actual contract. At the page type level:
At the store level:
At the iterator level:
That last one is important: with Migration plan I would do this in stages.
That gives you a clean incremental path. Recommended final shape If I had to boil it down to one target design, it would be this: package page
type Request[C any] struct {
Limit uint32
After *C
}
type Result[T any, C any] struct {
Items []T
Next *C
}type AccountStore interface {
ListAccounts(ctx context.Context, query ListAccountsQuery) (
page.Result[AccountInfo, AccountCursor], error,
)
IterAccounts(ctx context.Context, query ListAccountsQuery) iter.Seq2[AccountInfo, error]
}res, err := store.ListAccounts(ctx, query)
if err != nil {
return err
}
for _, item := range res.Items {
...
}
if res.Next != nil {
query.Page.After = res.Next
}That is the core redesign. It fixes the ergonomics, keeps pagination ownership in the right layer, and lets you simplify the implementation without over-abstracting it. |
41d431c to
8e63058
Compare
50ab4fc to
7952157
Compare
|
Regarding returning a page struct with the list inside, that was my initial approach. However, after looking at LND, I noticed it returns only the slice, with the cursor handled externally. Since termination can be inferred from an empty list, this seemed simpler at first. That said, I missed an important point you raised: this approach shifts the responsibility to the caller to understand how to assemble the cursor correctly. Because of that, I am now including the page struct to make the contract more explicit and reduce the risk of misuse. |
7952157 to
0708217
Compare
673b0ad to
d6824f2
Compare
d6824f2 to
92e2d93
Compare
92e2d93 to
9cc6dcb
Compare
9cc6dcb to
8927ab5
Compare
8927ab5 to
1a0036b
Compare
|
is this pr still in draft? |
1a0036b to
0a54320
Compare
yes, will be ready soon |
|
I ran benchmarks comparing the split queries, with and without cursor, against the unified approach. The results show the split version is generally about 7% faster. For PostgreSQL, the results are mixed, sometimes slightly faster, sometimes slightly slower, within a similar range. Full benchmark details are available here: https://github.com/GustavoStingelin/btcwallet/blob/6eb7ab030a1899aba0a92e5f12ea2c8ad1763caf/wallet/internal/db/itest/PAGINATION_REPORT.md Given that the performance difference is relatively small, I agree it is worth simplifying the implementation by using a single conditional cursored query, and I will do this. |
This PR adds pagination support to list queries that may return very large result sets, such as addresses.
List queries are now paginated by default. They accept an optional cursor parameter, which preserves compatibility with the existing contract.
New interface methods named
IterSomethingwere added. These provide seamless iteration over all results without requiring the caller to manage pagination, using theiter.Seq2API.