Skip to content

fix: migrate TransactionService::list_for_user to cursor/keyset pagination - #62

Merged
abayomicornelius merged 6 commits into
StellarSend:mainfrom
prodbycorne:fix/cursor-pagination-transactions-54
Aug 17, 2026
Merged

fix: migrate TransactionService::list_for_user to cursor/keyset pagination#62
abayomicornelius merged 6 commits into
StellarSend:mainfrom
prodbycorne:fix/cursor-pagination-transactions-54

Conversation

@prodbycorne

Copy link
Copy Markdown
Contributor

Summary

TransactionService::list_for_user ran a full COUNT(*) on every request and used OFFSET, which is O(offset) in Postgres — both well-known limitations that don't scale for large transaction histories.

  • Cursor/keyset pagination is now the default. list_for_user_cursor seeks from an opaque (created_at, id) cursor (WHERE (created_at, id) < (:cursor_created_at, :cursor_id) ORDER BY created_at DESC, id DESC LIMIT :per_page) instead of counting/skipping past N rows, so cost no longer grows with how deep into the history a page is, and no COUNT(*) runs unless the caller opts in.
  • Ordering by (created_at, id) rather than created_at alone means rows sharing an identical timestamp (e.g. batch-payment legs inserted within the same transaction) are still totally ordered — no gaps or duplicates across a page boundary that falls inside a tied group.
  • page/per_page still works as a legacy mode (list_for_user_offset, unchanged behavior) for existing consumers — passing page opts into the original COUNT(*)+OFFSET path.
  • include_total=true opts either mode into computing total/total_pages (always computed in legacy mode); PaginatedTransactions.total/.page/.total_pages become Option<_> since cursor mode doesn't compute them by default, plus a new next_cursor field for continuation.

Closes #54

Test plan

  • cargo test (unit tests, no DB) — cursor encode/decode round-trip + malformed-input rejection, all pass
  • cargo test -- --ignored against a real Postgres — 4 integration tests pass:
    • 125 seeded rows paged via cursor come back exactly once, no gaps/duplicates
    • a 12-row block sharing an identical created_at (simulating batch-payment legs) is paginated correctly across a page boundary that falls inside the tied group
    • legacy page/per_page mode still returns the same total/total_pages/items shape as before
    • cursor mode omits total/total_pages by default, computes them only with include_total=true
  • cargo deny check advisories / cargo deny check bans licenses sources — pass (the new base64 dependency reuses the exact version, 0.22.1, already present transitively — no new dependency version added to the tree)
  • cargo fmt --check on changed files — clean

Needed to encode/decode the (created_at, id) keyset-pagination cursor
for TransactionService::list_for_user (StellarSend#54) as an opaque string. 0.22
is already present in Cargo.lock transitively, so this adds no new
dependency version to the tree — cargo-deny's tracked
base64 0.13/0.21/0.22 duplicate-versions note is unaffected.
TransactionListParams gains cursor (opaque keyset cursor) and
include_total (opt-in COUNT(*)). PaginatedTransactions' total/page/
total_pages become Option<_> since cursor mode doesn't compute them
by default, plus a new next_cursor field for keyset continuation.
Splits list_for_user into two paths: list_for_user_cursor (new
default) seeks from an opaque (created_at, id) cursor instead of
counting/skipping past N rows, so cost no longer grows with how deep
into the history a page is and no COUNT(*) runs unless the caller
opts in via include_total. list_for_user_offset preserves the
original page/per_page/COUNT(*) behavior unchanged, used only when a
caller explicitly passes page.

Ordering by (created_at, id) rather than created_at alone means rows
sharing an identical timestamp (e.g. batch-payment legs inserted in
one transaction) are still totally ordered, so keyset pagination
can't skip or duplicate them across pages.

Closes StellarSend#54
Updates the route's doc comment with the new cursor/include_total
params and notes the page/per_page legacy mode's cost. Adds a short
note to docs/API.md's Payments section pointing at the same tradeoff.
Unit-level coverage for the two pure helpers backing keyset
pagination: a cursor round-trips through encode/decode at microsecond
precision, and malformed input (bad base64, wrong shape, invalid
UUID half) is rejected as AppError::BadRequest rather than panicking.
Integration tests (DATABASE_URL-gated, #[ignore]d, following
escrow::db_tests/reconciliation::db_tests) proving StellarSend#54's acceptance
criteria directly against real SQL rather than mocks:

- 125 seeded rows paged via cursor come back exactly once, no gaps or
  duplicates.
- A 12-row block sharing an identical created_at (simulating
  batch-payment legs inserted in one transaction) is still paginated
  correctly across a page boundary that falls inside the tied group.
- The legacy page/per_page path still returns the same total/
  total_pages/items shape as before.
- Cursor mode omits total/total_pages by default and only computes
  them when include_total=true is passed.

Run with: DATABASE_URL=... cargo test -- --ignored
@abayomicornelius

Copy link
Copy Markdown
Contributor

all checks passed

@abayomicornelius
abayomicornelius merged commit 8cc1032 into StellarSend:main Aug 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TransactionService::list_for_user uses OFFSET pagination with a full COUNT(*) on every request — doesn't scale for large histories

2 participants