store/LedgerReaderTx: replace per-ledger reads on LedgerReader and LedgerReaderTx with ScanLedgers iterator - #1005
Conversation
302516d to
36ac502
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The scan helper can loop indefinitely at MaxUint32, and v2 point scans can yield work after cancellation.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Replaces ledger point reads with a unified streaming ScanLedgers contract across v1 and v2 storage.
Changes:
- Adds snapshot-aware ledger scanning and point-read adapters.
- Migrates ledger/transaction handlers and tests to scans.
- Adds cross-backend performance benchmarks.
File summaries
| File | Description |
|---|---|
internal/store/ledger.go |
Defines scan contracts and adapters. |
internal/rpcv2/rpcv2test/rpcv2test.go |
Adds streaming fixtures and local client. |
internal/rpcv2/ledger_reads_bench_test.go |
Benchmarks ledger APIs across backends. |
internal/rpcv2/corestate/corestate_test.go |
Updates reader stub. |
internal/rpcv2/chunk/chunk.go |
Updates scan-bound documentation. |
internal/rpcv2/backfill/process_test.go |
Generalizes fixture callbacks. |
internal/rpcv2/adapters/view_context_test.go |
Uses point-read adapter. |
internal/rpcv2/adapters/ledger_reader.go |
Implements v2 scanning. |
internal/rpcv2/adapters/ledger_reader_test.go |
Tests v2 scan behavior. |
internal/rpcv1/sqlitedb/transaction_test.go |
Migrates ledger lookup. |
internal/rpcv1/sqlitedb/mocks.go |
Implements mock scanning. |
internal/rpcv1/sqlitedb/ledger.go |
Implements ordered SQL scanning. |
internal/rpcv1/sqlitedb/ledger_test.go |
Tests and benchmarks SQL scans. |
internal/methods/util.go |
Uses point-read adapter. |
internal/methods/util_test.go |
Updates utility tests. |
internal/methods/simulate_transaction.go |
Migrates preflight ledger lookup. |
internal/methods/simulate_transaction_test.go |
Updates serializable fixtures. |
internal/methods/mocks.go |
Adds scan-capable method mocks. |
internal/methods/json.go |
Accepts raw ledger/header bytes. |
internal/methods/get_transactions.go |
Streams ledger ranges with gap checks. |
internal/methods/get_transactions_test.go |
Tests scan gaps and limits. |
internal/methods/get_transactions_differential_test.go |
Adapts the frozen reference. |
internal/methods/get_ledgers.go |
Streams and formats ledger pages. |
internal/methods/get_ledgers_test.go |
Updates scan and parsing coverage. |
internal/methods/get_latest_ledger.go |
Uses the point-read helper. |
internal/methods/get_latest_ledger_test.go |
Updates constant reader fixture. |
Review details
- Files reviewed: 26/26 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
c4e80c8 to
f04df97
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Scan error propagation, maximum-range termination, and the stated benchmark acceptance regression remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
cmd/stellar-rpc/internal/store/ledger.go:86
- This inclusive
uint32loop never terminates whenend == math.MaxUint32: after processing the endpoint,seq++wraps to zero and the condition remains true. Iterate with a wider counter so every validuint32range terminates.
for seq := start; seq <= end; seq++ {
- Files reviewed: 27/27 changed files
- Comments generated: 3
- Review effort level: Balanced
f04df97 to
a5b4b6e
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The scan helper can overflow indefinitely, and getLedgers can suppress terminal scan errors.
Review details
Suppressed comments (2)
cmd/stellar-rpc/internal/store/ledger.go:86
- This loop does not terminate when
end == math.MaxUint32: after processing (or skipping) that sequence,seq++wraps to zero andseq <= endremains true. Make the endpoint check explicit so draining a scan that reaches the maximum ledger cannot loop forever.
for seq := start; seq <= end; seq++ {
cmd/stellar-rpc/internal/methods/get_ledgers.go:220
- Check
errbefore the page-limit break. ASeq2may emit a terminal error after its last successful ledger (the SQLite implementation does this viarows.Err()); onceresultreacheslimit, this ordering silently converts that cancellation/read failure into a successful response, unlike the previous batch path.
for ledger, err := range readTx.ScanLedgers(ctx, start, end) {
if len(result) >= int(limit) {
break
}
if err != nil {
- Files reviewed: 27/27 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
ScanLedgersFrom can loop forever when the inclusive range ends at math.MaxUint32.
Review effort: Balanced
Findings: 3
Open (5)
WithLedgerRawreturns from the range body on the first value, which makes the iterator'syield… Check the yielded error before the page-size guard. Onceresultreacheslimit, a final error… This inclusiveuint32loop never terminates whenend == math.MaxUint32: after processing the… The linked #1004 acceptance requires every benchmark row to remain flat or improve, but the PR… Cancellation can occur after the initial check and whileWithLedgeris reading; this callback…
767d631 to
3d6fec8
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The new LedgerReader documentation promises live reads although rpcv2 uses a request-frozen view.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (4)
WithLedgerRawreturns from the range body on the first value, which makes the iterator'syield… The linked #1004 acceptance requires every benchmark row to remain flat or improve, but the PR… Cancellation can occur after the initial check and whileWithLedgeris reading; this callback… Correct v2 read view consistency contract · New
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
The cross-backend storage contract and borrowed-buffer lifecycle changes warrant final human validation despite extensive tests.
Review effort: Balanced
Findings: None
Resolved since last review (5)
Removing suppression exceeds funlen limit and fails lintWithLedgerRawreturns from the range body on the first value, which makes the iterator'syield… Cancellation can occur after the initial check and whileWithLedgeris reading; this callback… Implementation contradicts benchmarked rendering behavior Correct v2 read view consistency contract
| return | ||
| } | ||
| var seq uint32 | ||
| var meta sql.RawBytes // database/sql's per-Rows buffer, reused by the next Scan: RawLedger's loan |
There was a problem hiding this comment.
A blob does not go through the shared per-Rows buffer. database/sql uses rows.rawbuf() only for string and time.Time sources; for a []byte source the *RawBytes case is a plain *d = s ($GOROOT/src/database/sql/convert.go:274-279), aliasing whatever the driver left in the row. With mattn/go-sqlite3 that is a fresh C.GoBytes copy per row (sqlite3.go:2228), so nothing is reused here and the next Scan overwrites nothing.
The loan itself is right and should stay. The standard library only promises the slice until the next Next, Scan or Close, another driver may well hand back a reused buffer, and on v2 the reuse is real.
This PR already had the accurate wording, in 93326c0, before e76193a rewrote it:
var meta sql.RawBytes // the driver's buffer, valid until the next Next: RawLedger's loan


What
store.LedgerReaderTxandstore.LedgerReadernow expose one read idiom:ScanLedgers(ctx, start, end) iter.Seq2[RawLedger, error]. EachRawLedgerlends its bytes for one loop body. The point reads becomestorehelpers over a scan of one (store.GetLedger,store.WithLedgerRaw), and both backends implement the scan once:sql.RawBytes, so database/sql lends the driver's buffer instead of cloning it; that is the loan the contract already promises.ScanLedgers, withstartraised to the view's floor so a below-floor request yields nothing instead of a*RangeError. A scan of one routes to the view's pinned point read behind the window gate, so getLatestLedger's I/O is unchanged.This replaces:
WithLedgerRawon the Tx,BatchGetLedgers,LedgerMetadataChunk, the adapter'swalk,walkSpanCapandrawHeaderFromLCMBytes, and the handlers'readLedgerPageandprocErr.[start, min(latest, start+LedgerScanLimit-1)]. A yielded sequence that is not the one expected, or a range that runs dry, returns the same InvalidParams naming the missing ledger as before.parseLedgerInfo. The datastore fallback marshals each ledger into the same function. See the benchmark section for why the page is collected first.Why
#1004. The point-read shape forced a walk protocol the adapter had to police, cloned every ledger of a getLedgers page only to encode it, and made a v2 in-window gap answer
InternalErrorwhere v1 answersInvalidParams. The scan makes sequential access the contract.Extra Notes
Behavior
Wire output is unchanged on every pinned path. The benchmark's response-byte metric is identical on all 21 rows. One case converges: a v2 in-window hot-store gap now returns v1's
InvalidParams. Missing ledgers stay silent for getLedgers, as this is the behavior that we have today.Tests
The
getTransactionsdifferential suite passes unchanged, including error parity. Added: scan-gap and dry-tail cells forgetTransactions; ordering, gap, below-floor, empty-range, cancellation, early-break and chunk-border tests for the adapter; an ordering and gap test for sqlite; aparseLedgerInfoheader test over all three LCM wire versions; a getLedgers test that a scan error yielded after a full page is reported rather than dropped; aScanLedgersFromtest that an inclusive range ending atMaxUint32terminates. Removed: the fourteen tests on the retired walk and batch surface.The getEvents differential suite (from #982) now validates the scan as well. Its frozen reference no longer reads the events table, and instead re-derives each request window's events from the ledgers themselves, one ScanLedgers pass over the window. This new scan approach and the old events table, its SQL prefilters and the view path are each other's oracles.
One tangential optimization
In
xdr2json/conversion.go,convertAnyBytescopied the JSON out of C twice:C.GoStringallocates a Go string and memcpys the C bytes into it, thenjson.RawMessage(jsonStr)converts string to byte slice, which must allocate and copy again because strings are immutable. A singleC.GoBytesdoes one copy. It sits on the hot path of every JSON response, and the eight-line change is worth 8 to 11% of bytes/op on the JSON rows across all three backends.Benchmark
BenchmarkLedgerReads(rpcv2):getTransactions,getLedgersandgetLatestLedgerover 865 real pubnet ledgers on v2-hot, v2-cold and v1-sqlite through an in-memory JSON-RPC pipe. Interleaved builds,-count 6, M4 Pro, base vs head:[1] Measured with a page clone before rendering that a later commit removed. The removal alone (ABBA, benchtime 3x, n=4/side): hot json -3.5% time (p=0.057), -3.8% bytes/op; cold flat; allocs -11% to -15% on both.
[2] +8% (p=0.04) in the full-suite run; an eight-round rerun of the row alone: 15.59s vs 15.54s (p=0.57).
[3] The two-column scan and the scan-of-one closure, a handful of allocations per request.
Known Limitations
N/A