diff --git a/cmd/stellar-rpc/internal/methods/get_latest_ledger.go b/cmd/stellar-rpc/internal/methods/get_latest_ledger.go index 074dea190..a646ce39d 100644 --- a/cmd/stellar-rpc/internal/methods/get_latest_ledger.go +++ b/cmd/stellar-rpc/internal/methods/get_latest_ledger.go @@ -3,11 +3,13 @@ package methods import ( "context" "encoding/base64" + "encoding/hex" "fmt" "github.com/creachadair/jrpc2" protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) @@ -23,39 +25,48 @@ func NewGetLatestLedgerHandler(ledgerReader store.LedgerReader) jrpc2.Handler { Message: "could not get latest ledger sequence", } } - latestLedger, found, err := ledgerReader.GetLedger(ctx, latestSequence) - if (err != nil) || (!found) { - return protocol.GetLatestLedgerResponse{}, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: "could not get latest ledger", - } - } - header := latestLedger.LedgerHeaderHistoryEntry().Header - headerBytes, err := header.MarshalBinary() - if err != nil { - return protocol.GetLatestLedgerResponse{}, &jrpc2.Error{ - Code: jrpc2.InternalError, - Message: fmt.Sprintf("could not marshal latest ledger header: %v", err), + var response protocol.GetLatestLedgerResponse + var parseErr error + found, err := ledgerReader.WithLedgerRaw(ctx, latestSequence, func(raw []byte) error { + response, parseErr = latestLedgerResponse(xdr.LedgerCloseMetaView(raw), latestSequence) + return parseErr + }) + if err != nil || !found { + var msg string + switch { + case parseErr != nil: + msg = fmt.Sprintf("could not parse latest ledger header: %v", parseErr) + case err != nil: + msg = fmt.Sprintf("could not get latest ledger: %v", err) + default: // clean miss: no underlying error to report + msg = "could not get latest ledger" } - } - response := protocol.GetLatestLedgerResponse{ - Hash: latestLedger.LedgerHash().HexString(), - ProtocolVersion: latestLedger.ProtocolVersion(), - Sequence: latestSequence, - LedgerCloseTime: latestLedger.LedgerCloseTime(), - LedgerHeader: base64.StdEncoding.EncodeToString(headerBytes), - } - - raw, err := latestLedger.MarshalBinary() - if err != nil { return protocol.GetLatestLedgerResponse{}, &jrpc2.Error{ Code: jrpc2.InternalError, - Message: fmt.Sprintf("could not marshal latest ledger metadata: %v", err), + Message: msg, } } - response.LedgerMetadata = base64.StdEncoding.EncodeToString(raw) - return response, nil } return NewHandler(coreHandler) } + +// latestLedgerResponse extracts the response fields from a ledger close meta view. +func latestLedgerResponse(view xdr.LedgerCloseMetaView, sequence uint32, +) (protocol.GetLatestLedgerResponse, error) { + headerEntry, err := view.LedgerHeader() + if err != nil { + return protocol.GetLatestLedgerResponse{}, err + } + return xdr.Try(func() protocol.GetLatestLedgerResponse { + header := headerEntry.MustHeader() + return protocol.GetLatestLedgerResponse{ + Hash: hex.EncodeToString(headerEntry.MustHash().MustRaw()), + ProtocolVersion: header.MustLedgerVersion().MustValue(), + Sequence: sequence, + LedgerCloseTime: int64(header.MustScpValue().MustCloseTime().MustValue()), //nolint:gosec // safe for ~292B years + LedgerHeader: base64.StdEncoding.EncodeToString(header.MustRaw()), + LedgerMetadata: base64.StdEncoding.EncodeToString(view), + } + }) +} diff --git a/cmd/stellar-rpc/internal/methods/get_latest_ledger_test.go b/cmd/stellar-rpc/internal/methods/get_latest_ledger_test.go index 2efc67e40..cefecd0ff 100644 --- a/cmd/stellar-rpc/internal/methods/get_latest_ledger_test.go +++ b/cmd/stellar-rpc/internal/methods/get_latest_ledger_test.go @@ -45,6 +45,19 @@ func (ledgerReader *ConstantLedgerReader) GetLedger(_ context.Context, true, nil } +func (ledgerReader *ConstantLedgerReader) WithLedgerRaw( + _ context.Context, sequence uint32, fn store.WithLedgerRawFn, +) (bool, error) { + lcm := createLedger(expectedLatestLedgerHashBytes, + sequence, + expectedLatestLedgerCloseTime) + raw, err := lcm.MarshalBinary() + if err != nil { + return false, err + } + return true, fn(raw) +} + func (ledgerReader *ConstantLedgerReader) StreamLedgerRange( _ context.Context, _ uint32, diff --git a/cmd/stellar-rpc/internal/methods/get_transactions_test.go b/cmd/stellar-rpc/internal/methods/get_transactions_test.go index ba770956c..40a56fed6 100644 --- a/cmd/stellar-rpc/internal/methods/get_transactions_test.go +++ b/cmd/stellar-rpc/internal/methods/get_transactions_test.go @@ -381,6 +381,17 @@ func (r *sparseLedgerReader) GetLedger(_ context.Context, seq uint32) (xdr.Ledge return createEmptyTestLedger(seq), true, nil } +func (r *sparseLedgerReader) WithLedgerRaw( + _ context.Context, seq uint32, fn store.WithLedgerRawFn, +) (bool, error) { + r.gets++ + raw, err := createEmptyTestLedger(seq).MarshalBinary() + if err != nil { + return false, err + } + return true, fn(raw) +} + func (r *sparseLedgerReader) GetLedgerRange(context.Context) (store.LedgerRange, error) { return store.LedgerRange{ FirstLedger: store.LedgerInfo{Sequence: 1, CloseTime: 100}, diff --git a/cmd/stellar-rpc/internal/methods/mocks.go b/cmd/stellar-rpc/internal/methods/mocks.go index f40d487a5..7f9094108 100644 --- a/cmd/stellar-rpc/internal/methods/mocks.go +++ b/cmd/stellar-rpc/internal/methods/mocks.go @@ -27,6 +27,13 @@ func (m *MockLedgerReader) GetLedger(ctx context.Context, sequence uint32) (xdr. return args.Get(0).(xdr.LedgerCloseMeta), args.Bool(1), args.Error(2) //nolint:forcetypeassert } +func (m *MockLedgerReader) WithLedgerRaw( + ctx context.Context, sequence uint32, fn store.WithLedgerRawFn, +) (bool, error) { + args := m.Called(ctx, sequence, fn) + return args.Bool(0), args.Error(1) +} + func (m *MockLedgerReader) GetLedgerRange(ctx context.Context) (store.LedgerRange, error) { args := m.Called(ctx) return args.Get(0).(store.LedgerRange), args.Error(1) //nolint:forcetypeassert diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go index b50f6bdec..8a235c89b 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go @@ -2,7 +2,6 @@ package sqlitedb import ( - "bytes" "context" "database/sql" "errors" @@ -74,28 +73,15 @@ func (l ledgerReaderTx) BatchGetLedgers( batch := make([]store.LedgerMetadataChunk, len(results)) for i, meta := range results { - batch[i] = store.LedgerMetadataChunk{Lcm: meta} - - var v xdr.Int32 - rd := bytes.NewReader(meta) - if _, err := xdr.Unmarshal(rd, &v); err != nil { + headerView, err := xdr.LedgerCloseMetaView(meta).LedgerHeader() + if err != nil { return nil, err } - - if v > 0 { // V0 has no extension - var ext xdr.LedgerCloseMetaExt - if _, err := xdr.Unmarshal(rd, &ext); err != nil { // skipped - return nil, err - } - } - - hdrStart := len(meta) - rd.Len() - var hdr xdr.LedgerHeaderHistoryEntry - n, err := xdr.Unmarshal(rd, &hdr) + headerRaw, err := headerView.Raw() if err != nil { return nil, err } - batch[i].HeaderRaw = meta[hdrStart : hdrStart+n] + batch[i] = store.LedgerMetadataChunk{HeaderRaw: headerRaw, Lcm: meta} } return batch, nil @@ -163,6 +149,15 @@ func (r ledgerReader) GetLedger(ctx context.Context, sequence uint32) (xdr.Ledge return getLedgerFromDB(ctx, r.db, sequence) } +// WithLedgerRaw lends the ledger's stored meta blob without decoding it. +func (r ledgerReader) WithLedgerRaw(ctx context.Context, sequence uint32, fn store.WithLedgerRawFn) (bool, error) { + meta, found, err := getLedgerRawFromDB(ctx, r.db, sequence) + if err != nil || !found { + return found, err + } + return true, fn(meta) +} + // GetLedgerRange pulls the min/max ledger sequence numbers from the meta table. func (r ledgerReader) GetLedgerRange(ctx context.Context) (store.LedgerRange, error) { r.db.cache.RLock() @@ -223,30 +218,60 @@ func (r ledgerReader) GetLatestLedgerSequence(ctx context.Context) (uint32, erro return getLatestLedgerSequence(ctx, r, r.db.cache) } +// ledgerCloseTimePrefixBytes is the fast-path meta prefix fetched for range +// endpoints. Parsing falls back to the full blob if the header extends past it. +const ledgerCloseTimePrefixBytes = 1024 + +// ledgerRangeRow is one endpoint of the stored ledger range. +type ledgerRangeRow struct { + Sequence uint32 `db:"sequence"` + MetaPrefix []byte `db:"meta_prefix"` +} + +// ledgerInfoFromRow reads the close time out of the row's meta prefix, +// refetching the full blob for rare metas whose close time lies beyond it. +func ledgerInfoFromRow(ctx context.Context, db readDB, row ledgerRangeRow) (store.LedgerInfo, error) { + closeTime, err := xdr.LedgerCloseMetaView(row.MetaPrefix).LedgerCloseTime() + if err != nil { + meta, found, dbErr := getLedgerRawFromDB(ctx, db, row.Sequence) + if dbErr != nil { + return store.LedgerInfo{}, dbErr + } + if found { + closeTime, err = xdr.LedgerCloseMetaView(meta).LedgerCloseTime() + } + if err != nil { + return store.LedgerInfo{}, fmt.Errorf("couldn't get ledger %d close time: %w", row.Sequence, err) + } + } + return store.LedgerInfo{Sequence: row.Sequence, CloseTime: closeTime}, nil +} + // getLedgerRangeWithCache uses the latest ledger cache to optimize the query. // It only needs to look up the first ledger since we have the latest cached. func getLedgerRangeWithCache(ctx context.Context, db readDB, latestSeq uint32, latestTime int64, ) (store.LedgerRange, error) { - query := sq.Select("meta"). + query := sq.Select("sequence", fmt.Sprintf("substr(meta, 1, %d) AS meta_prefix", ledgerCloseTimePrefixBytes)). From(ledgerCloseMetaTableName). Where( fmt.Sprintf("sequence = (SELECT MIN(sequence) FROM %s)", ledgerCloseMetaTableName), ) - var lcm []xdr.LedgerCloseMeta - if err := db.Select(ctx, &lcm, query); err != nil { + var rows []ledgerRangeRow + if err := db.Select(ctx, &rows, query); err != nil { return store.LedgerRange{}, fmt.Errorf("couldn't query ledger range: %w", err) } - if len(lcm) == 0 { + if len(rows) == 0 { return store.LedgerRange{}, store.ErrEmptyDB } + firstLedger, err := ledgerInfoFromRow(ctx, db, rows[0]) + if err != nil { + return store.LedgerRange{}, err + } return store.LedgerRange{ - FirstLedger: store.LedgerInfo{ - Sequence: lcm[0].LedgerSequence(), - CloseTime: lcm[0].LedgerCloseTime(), - }, + FirstLedger: firstLedger, LastLedger: store.LedgerInfo{ Sequence: latestSeq, CloseTime: latestTime, @@ -256,31 +281,34 @@ func getLedgerRangeWithCache(ctx context.Context, db readDB, // getLedgerRangeWithoutCache queries both the first and last ledger when cache isn't available func getLedgerRangeWithoutCache(ctx context.Context, db readDB) (store.LedgerRange, error) { - query := sq.Select("lcm.meta"). + query := sq.Select("lcm.sequence", fmt.Sprintf("substr(lcm.meta, 1, %d) AS meta_prefix", ledgerCloseTimePrefixBytes)). From(ledgerCloseMetaTableName + " as lcm"). Where(sq.Or{ sq.Expr("lcm.sequence = (?)", sq.Select("MIN(sequence)").From(ledgerCloseMetaTableName)), sq.Expr("lcm.sequence = (?)", sq.Select("MAX(sequence)").From(ledgerCloseMetaTableName)), }).OrderBy("lcm.sequence ASC") - var lcms []xdr.LedgerCloseMeta - if err := db.Select(ctx, &lcms, query); err != nil { + var rows []ledgerRangeRow + if err := db.Select(ctx, &rows, query); err != nil { return store.LedgerRange{}, fmt.Errorf("couldn't query ledger range: %w", err) } - if len(lcms) == 0 { + if len(rows) == 0 { return store.LedgerRange{}, store.ErrEmptyDB } + firstLedger, err := ledgerInfoFromRow(ctx, db, rows[0]) + if err != nil { + return store.LedgerRange{}, err + } + lastLedger, err := ledgerInfoFromRow(ctx, db, rows[len(rows)-1]) + if err != nil { + return store.LedgerRange{}, err + } + return store.LedgerRange{ - FirstLedger: store.LedgerInfo{ - Sequence: lcms[0].LedgerSequence(), - CloseTime: lcms[0].LedgerCloseTime(), - }, - LastLedger: store.LedgerInfo{ - Sequence: lcms[len(lcms)-1].LedgerSequence(), - CloseTime: lcms[len(lcms)-1].LedgerCloseTime(), - }, + FirstLedger: firstLedger, + LastLedger: lastLedger, }, nil } @@ -326,21 +354,34 @@ func (l ledgerWriter) trimLedgers(latestLedgerSeq uint32, retentionWindow uint32 return err } -// getLedgerFromDB is a helper function that encapsulates the common logic -// for fetching a single ledger from the database +// getLedgerFromDB fetches a single ledger from the database. func getLedgerFromDB(ctx context.Context, db readDB, sequence uint32) (xdr.LedgerCloseMeta, bool, error) { + meta, found, err := getLedgerRawFromDB(ctx, db, sequence) + if err != nil || !found { + return xdr.LedgerCloseMeta{}, false, err + } + var lcm xdr.LedgerCloseMeta + if err := lcm.UnmarshalBinary(meta); err != nil { + return xdr.LedgerCloseMeta{}, false, err + } + return lcm, true, nil +} + +// getLedgerRawFromDB is a helper function that encapsulates the common logic +// for fetching a single ledger's bytes from the database. +func getLedgerRawFromDB(ctx context.Context, db readDB, sequence uint32) ([]byte, bool, error) { sql := sq.Select("meta").From(ledgerCloseMetaTableName).Where(sq.Eq{"sequence": sequence}) - var results []xdr.LedgerCloseMeta + var results [][]byte if err := db.Select(ctx, &results, sql); err != nil { - return xdr.LedgerCloseMeta{}, false, err + return nil, false, err } switch len(results) { case 0: - return xdr.LedgerCloseMeta{}, false, nil + return nil, false, nil case 1: return results[0], true, nil default: - return xdr.LedgerCloseMeta{}, false, fmt.Errorf("multiple lcm entries (%d) for sequence %d in table %q", + return nil, false, fmt.Errorf("multiple lcm entries (%d) for sequence %d in table %q", len(results), sequence, ledgerCloseMetaTableName) } } diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go index bb4d2360c..eb81dd7dc 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go @@ -1,6 +1,7 @@ package sqlitedb import ( + "bytes" "context" "io" "path" @@ -113,6 +114,25 @@ func TestLedgers(t *testing.T) { assertLedgerRange(t, reader, 8, 12) } +// TestLedgerInfoFromRow_PrefixFallback covers a meta prefix too short to reach +// the close time, which must fall back to reading the full blob. +func TestLedgerInfoFromRow_PrefixFallback(t *testing.T) { + db := NewTestDB(t) + tx, err := NewReadWriter(logger, db, host.MakeNoOpDaemon(), 15, passphrase).NewTx(t.Context()) + require.NoError(t, err) + lcm := createLedger(42) + require.NoError(t, tx.LedgerWriter().InsertLedger(lcm)) + require.NoError(t, tx.Commit(lcm, nil)) + + raw, err := lcm.MarshalBinary() + require.NoError(t, err) + + info, err := ledgerInfoFromRow(t.Context(), db, ledgerRangeRow{Sequence: 42, MetaPrefix: raw[:8]}) + require.NoError(t, err) + assert.Equal(t, uint32(42), info.Sequence) + assert.Equal(t, lcm.LedgerCloseTime(), info.CloseTime) +} + func TestGetLedgerRange_NonEmptyDB(t *testing.T) { db := NewTestDB(t) ctx := context.TODO() @@ -233,6 +253,38 @@ func TestGetLedgerRange_EmptyDB(t *testing.T) { assert.Equal(t, int64(0), ledgerRange.LastLedger.CloseTime) } +// TestWithLedgerRaw covers both lend outcomes: a hit lends the stored meta +// blob verbatim, and a miss reports found=false without running fn. +func TestWithLedgerRaw(t *testing.T) { + db := NewTestDB(t) + tx, err := NewReadWriter(logger, db, host.MakeNoOpDaemon(), 15, passphrase).NewTx(t.Context()) + require.NoError(t, err) + lcm := createLedger(42) + require.NoError(t, tx.LedgerWriter().InsertLedger(lcm)) + require.NoError(t, tx.Commit(lcm, nil)) + want, err := lcm.MarshalBinary() + require.NoError(t, err) + + reader := NewLedgerReader(db) + var got []byte + found, err := reader.WithLedgerRaw(t.Context(), 42, func(raw []byte) error { + got = bytes.Clone(raw) + return nil + }) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, want, got) + + ran := false + found, err = reader.WithLedgerRaw(t.Context(), 43, func([]byte) error { + ran = true + return nil + }) + require.NoError(t, err) + assert.False(t, found) + assert.False(t, ran) +} + func BenchmarkGetLedgerRange(b *testing.B) { testDB, lcms := setupBenchmarkingDB(b) reader := NewLedgerReader(testDB) @@ -267,6 +319,62 @@ func BenchmarkBatchGetLedgers(b *testing.B) { } } +// padLedger grows a txMeta ledger's meta to roughly size bytes via its soroban return value. +func padLedger(lcm xdr.LedgerCloseMeta, size int) xdr.LedgerCloseMeta { + payload := xdr.ScBytes(make([]byte, size)) + lcm.V2.TxProcessing[0].TxApplyProcessing.V3.SorobanMeta.ReturnValue = xdr.ScVal{ + Type: xdr.ScValTypeScvBytes, + Bytes: &payload, + } + return lcm +} + +// BenchmarkOldestLedgerRangeLookup measures the 1KiB prefix fetch in +// getLedgerRangeWithCache. The tx read path (getLedgers/getTransactions) runs +// this lookup once per request. +func BenchmarkOldestLedgerRangeLookup(b *testing.B) { + for _, tc := range []struct { + name string + size int + }{ + // min/avg/max meta blob sizes observed on a pubnet 7-day node + {"512KiB", 512 << 10}, + {"2MiB", 2 << 20}, + {"4MiB", 4 << 20}, + } { + ctx := b.Context() + testDB := NewTestDB(b) + writer := NewReadWriter(logger, testDB, host.MakeNoOpDaemon(), 1_000_000, passphrase) + write, err := writer.NewTx(ctx) + require.NoError(b, err) + + lcms := []xdr.LedgerCloseMeta{ + padLedger(txMeta(1000, true), tc.size), + padLedger(txMeta(1001, true), tc.size), + } + ledgerW, txW := write.LedgerWriter(), write.TransactionWriter() + for _, lcm := range lcms { + require.NoError(b, ledgerW.InsertLedger(lcm)) + require.NoError(b, txW.InsertTransactions(lcm)) + } + latest := lcms[len(lcms)-1] + require.NoError(b, write.Commit(latest, nil)) + latestSeq, latestTime := latest.LedgerSequence(), latest.LedgerCloseTime() + + got, err := getLedgerRangeWithCache(ctx, testDB, latestSeq, latestTime) + require.NoError(b, err) + require.Equal(b, lcms[0].LedgerSequence(), got.FirstLedger.Sequence) + + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + _, err := getLedgerRangeWithCache(ctx, testDB, latestSeq, latestTime) + require.NoError(b, err) + } + }) + } +} + func NewTestDB(tb testing.TB) *DB { tmp := tb.TempDir() dbPath := path.Join(tmp, "db.sqlite") diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go index 8f1c97fc1..5930dd950 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/mocks.go @@ -97,6 +97,18 @@ func (m *MockLedgerReader) GetLedger(_ context.Context, sequence uint32) (xdr.Le return *lcm, true, nil } +func (m *MockLedgerReader) WithLedgerRaw(_ context.Context, sequence uint32, fn store.WithLedgerRawFn) (bool, error) { + lcm, ok := m.txn.ledgerSeqToMeta[sequence] + if !ok { + return false, nil + } + rawMeta, err := lcm.MarshalBinary() + if err != nil { + return false, err + } + return true, fn(rawMeta) +} + func (m *MockLedgerReader) StreamLedgerRange(_ context.Context, _ uint32, _ uint32, _ store.StreamLedgerFn) error { return nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go index b72bb1c94..b3efa9023 100644 --- a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader.go @@ -55,6 +55,28 @@ func (r *LedgerReader) GetLedger(ctx context.Context, sequence uint32) (xdr.Ledg return lcm, found, err } +// WithLedgerRaw lends the ledger's raw bytes with no copy: the routed point +// read lends the tier's buffer, whose validity ends with fn — exactly the +// loan's terms. +func (r *LedgerReader) WithLedgerRaw(ctx context.Context, sequence uint32, fn store.WithLedgerRawFn) (bool, error) { + view, err := query.ViewFrom(ctx) + if err != nil { + return false, err + } + if !inWindow(view, sequence) { + return false, nil + } + found := false + err = view.WithLedger(sequence, func(raw []byte) error { + found = true + return fn(raw) + }) + if !found && errors.Is(err, stores.ErrNotFound) { + return false, nil + } + return found, err +} + func (r *LedgerReader) GetLedgerRange(ctx context.Context) (store.LedgerRange, error) { view, err := query.ViewFrom(ctx) if err != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go index ae7a4bbf3..70f9ef6d2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/adapters/ledger_reader_test.go @@ -485,3 +485,45 @@ func TestGetLedgerRange_SeededWindowReadsNoLedgers(t *testing.T) { assert.Less(t, perCall, uint64(2048), "a seeded GetLedgerRange allocated %d bytes per call; it should not be reading ledgers", perCall) } + +func TestWithLedgerRaw_LendsTheSameBytesGetLedgerDecodes(t *testing.T) { + ctx, reader, c0, _ := sparseFixture(t) + lcm, ok, err := reader.GetLedger(ctx, c0.FirstLedger()) + require.NoError(t, err) + require.True(t, ok) + want, err := lcm.MarshalBinary() + require.NoError(t, err) + + var got []byte + found, err := reader.WithLedgerRaw(ctx, c0.FirstLedger(), func(raw []byte) error { + got = bytes.Clone(raw) // the loan forbids retaining raw + return nil + }) + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, want, got) +} + +func TestWithLedgerRaw_MissDoesNotRunFn(t *testing.T) { + ctx, reader, c0, c1 := sparseFixture(t) + // below the floor, in an in-window gap, and above latest + for _, seq := range []uint32{c0.FirstLedger() - 1, c0.FirstLedger() + 10, c1.FirstLedger() + 1} { + ran := false + found, err := reader.WithLedgerRaw(ctx, seq, func([]byte) error { + ran = true + return nil + }) + assert.NoError(t, err, "ledger %d", seq) + assert.False(t, found, "ledger %d", seq) + assert.False(t, ran, "fn must not run for an absent ledger %d", seq) + } +} + +func TestWithLedgerRaw_CallbackErrorSurfacesAsFound(t *testing.T) { + ctx, reader, c0, _ := sparseFixture(t) + boom := errors.New("boom") + // found stays true: the ledger WAS there, the caller's own callback failed. + found, err := reader.WithLedgerRaw(ctx, c0.FirstLedger(), func([]byte) error { return boom }) + assert.ErrorIs(t, err, boom) + assert.True(t, found) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/corestate/corestate_test.go b/cmd/stellar-rpc/internal/rpcv2/corestate/corestate_test.go index 5214f1d47..9596fb217 100644 --- a/cmd/stellar-rpc/internal/rpcv2/corestate/corestate_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/corestate/corestate_test.go @@ -214,6 +214,10 @@ func (s stubLedgerReader) GetLedger(context.Context, uint32) (xdr.LedgerCloseMet return xdr.LedgerCloseMeta{}, false, errors.New("unused") } +func (s stubLedgerReader) WithLedgerRaw(context.Context, uint32, store.WithLedgerRawFn) (bool, error) { + return false, errors.New("unused") +} + func (s stubLedgerReader) GetLedgerRange(context.Context) (store.LedgerRange, error) { return store.LedgerRange{}, errors.New("unused") } diff --git a/cmd/stellar-rpc/internal/store/ledger.go b/cmd/stellar-rpc/internal/store/ledger.go index 69d1c71ba..e589ae752 100644 --- a/cmd/stellar-rpc/internal/store/ledger.go +++ b/cmd/stellar-rpc/internal/store/ledger.go @@ -36,12 +36,17 @@ func (lr LedgerRange) ToLedgerSeqRange() protocol.LedgerSeqRange { // implements. Handlers depend on this interface, never on a concrete backend. type LedgerReader interface { GetLedger(ctx context.Context, sequence uint32) (xdr.LedgerCloseMeta, bool, error) + WithLedgerRaw(ctx context.Context, sequence uint32, fn WithLedgerRawFn) (found bool, err error) GetLedgerRange(ctx context.Context) (LedgerRange, error) StreamLedgerRange(ctx context.Context, startLedger uint32, endLedger uint32, f StreamLedgerFn) error NewTx(ctx context.Context) (LedgerReaderTx, error) GetLatestLedgerSequence(ctx context.Context) (uint32, error) } +// WithLedgerRawFn receives one ledger's marshaled LCM on loan and the bytes +// are valid only inside the call, read-only. Copy whatever outlives fn. +type WithLedgerRawFn func(raw []byte) error + // LedgerReaderTx is a read-only snapshot of the ledger store. Call Done to // release it. //