diff --git a/cmd/stellar-rpc/internal/methods/get_events.go b/cmd/stellar-rpc/internal/methods/get_events.go index 13604da29..fa2451b47 100644 --- a/cmd/stellar-rpc/internal/methods/get_events.go +++ b/cmd/stellar-rpc/internal/methods/get_events.go @@ -30,7 +30,6 @@ type eventsRPCHandler struct { maxLimit uint defaultLimit uint logger *log.Entry - ledgerReader store.LedgerReader } func combineContractIDs(filters []protocol.EventFilter) ([][]byte, error) { @@ -128,7 +127,19 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve } } - ledgerRange, err := h.ledgerReader.GetLedgerRange(ctx) + // Range and scan share one snapshot, so a validated startLedger cannot be + // trimmed away between the check and the query. + readTx, err := h.dbReader.NewTx(ctx) + if err != nil { + return protocol.GetEventsResponse{}, &jrpc2.Error{ + Code: jrpc2.InternalError, Message: err.Error(), + } + } + defer func() { + _ = readTx.Done() + }() + + ledgerRange, err := readTx.GetLedgerRange(ctx) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InternalError, Message: err.Error(), @@ -231,7 +242,7 @@ func (h eventsRPCHandler) getEvents(ctx context.Context, request protocol.GetEve return uint(len(results)) < limit, nil } - err = h.dbReader.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventViewScanFunction) + err = readTx.GetEvents(ctx, cursorRange, contractIDs, topics, eventTypes, eventViewScanFunction) switch { case procErr != nil: return protocol.GetEventsResponse{}, procErr @@ -398,14 +409,12 @@ func NewGetEventsHandler( dbReader store.EventReader, maxLimit uint, defaultLimit uint, - ledgerReader store.LedgerReader, ) jrpc2.Handler { eventsHandler := eventsRPCHandler{ dbReader: dbReader, maxLimit: maxLimit, defaultLimit: defaultLimit, logger: logger, - ledgerReader: ledgerReader, } return NewHandler(eventsHandler.getEvents) } diff --git a/cmd/stellar-rpc/internal/methods/get_events_differential_test.go b/cmd/stellar-rpc/internal/methods/get_events_differential_test.go index 4726f13b6..3bdb780df 100644 --- a/cmd/stellar-rpc/internal/methods/get_events_differential_test.go +++ b/cmd/stellar-rpc/internal/methods/get_events_differential_test.go @@ -230,7 +230,8 @@ type legacyEventEntry struct { // legacyGetEvents is the pre-view handler over ledger-derived events: the // frozen matcher and renderer on what legacyScanEvents finds. -func legacyGetEvents(ctx context.Context, h eventsRPCHandler, request protocol.GetEventsRequest, +func legacyGetEvents(ctx context.Context, h eventsRPCHandler, ledgerReader store.LedgerReader, + request protocol.GetEventsRequest, ) (protocol.GetEventsResponse, error) { if err := request.Valid(h.maxLimit); err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ @@ -238,7 +239,7 @@ func legacyGetEvents(ctx context.Context, h eventsRPCHandler, request protocol.G } } - ledgerRange, err := h.ledgerReader.GetLedgerRange(ctx) + ledgerRange, err := ledgerReader.GetLedgerRange(ctx) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InternalError, Message: err.Error(), @@ -289,7 +290,7 @@ func legacyGetEvents(ctx context.Context, h eventsRPCHandler, request protocol.G } } - found, err := legacyScanEvents(ctx, h.ledgerReader, cursorRange, &request, limit) + found, err := legacyScanEvents(ctx, ledgerReader, cursorRange, &request, limit) if err != nil { return protocol.GetEventsResponse{}, &jrpc2.Error{ Code: jrpc2.InvalidRequest, Message: err.Error(), @@ -686,14 +687,14 @@ type eventsDifferential = differential[protocol.GetEventsRequest, protocol.GetEv // handler. The small default limit lands inside the corpus. func newEventsDifferential(testDB *sqlitedb.DB) eventsDifferential { h := eventsRPCHandler{ - dbReader: sqlitedb.NewEventReader(log.DefaultLogger, testDB, passphrase), + dbReader: sqlitedb.NewEventReader(log.DefaultLogger, testDB), maxLimit: 10000, defaultLimit: 10, - ledgerReader: sqlitedb.NewLedgerReader(testDB), } + ledgerReader := sqlitedb.NewLedgerReader(testDB) return eventsDifferential{ want: func(ctx context.Context, req protocol.GetEventsRequest) (protocol.GetEventsResponse, error) { - return legacyGetEvents(ctx, h, req) + return legacyGetEvents(ctx, h, ledgerReader, req) }, got: h.getEvents, } diff --git a/cmd/stellar-rpc/internal/methods/get_events_test.go b/cmd/stellar-rpc/internal/methods/get_events_test.go index 8f99acca1..0fcc7b9aa 100644 --- a/cmd/stellar-rpc/internal/methods/get_events_test.go +++ b/cmd/stellar-rpc/internal/methods/get_events_test.go @@ -45,7 +45,7 @@ func TestGetEvents(t *testing.T) { write, err := writer.NewTx(ctx) require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) var txMeta []xdr.TransactionMeta txMeta = append(txMeta, transactionMetaWithEvents( @@ -71,7 +71,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } _, err = handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -95,7 +94,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) contractID := xdr.ContractId([32]byte{}) var txMeta []xdr.TransactionMeta @@ -124,7 +123,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -184,7 +182,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) var txMeta []xdr.TransactionMeta contractIDs := []xdr.ContractId{ @@ -216,7 +214,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -250,7 +247,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) var txMeta []xdr.TransactionMeta contractID := xdr.ContractId([32]byte{}) @@ -279,7 +276,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -389,7 +385,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) var txMeta []xdr.TransactionMeta contractID := xdr.ContractId([32]byte{}) @@ -421,7 +417,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } id := protocol.Cursor{Ledger: 1, Tx: 5, Op: 0, Event: 0}.String() @@ -584,7 +579,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) contractID := xdr.ContractId([32]byte{}) otherContractID := xdr.ContractId([32]byte{1}) @@ -643,7 +638,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -707,7 +701,7 @@ func TestGetEvents(t *testing.T) { write, err := writer.NewTx(ctx) require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) contractID := xdr.ContractId([32]byte{}) txMeta := []xdr.TransactionMeta{ @@ -744,7 +738,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -796,7 +789,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) contractID := xdr.ContractId([32]byte{}) var txMeta []xdr.TransactionMeta @@ -821,7 +814,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ StartLedger: 1, @@ -879,7 +871,7 @@ func TestGetEvents(t *testing.T) { require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) contractID := xdr.ContractId([32]byte{}) datas := []xdr.ScSymbol{ @@ -933,7 +925,6 @@ func TestGetEvents(t *testing.T) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } results, err := handler.getEvents(context.TODO(), protocol.GetEventsRequest{ Pagination: &protocol.PaginationOptions{ @@ -1019,7 +1010,7 @@ func BenchmarkGetEventsTopicFilters(b *testing.B) { require.NoError(b, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) const ( totalEvents = 5000 @@ -1081,7 +1072,6 @@ func BenchmarkGetEventsTopicFilters(b *testing.B) { dbReader: store, maxLimit: 10000, defaultLimit: 1000, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } req := protocol.GetEventsRequest{ @@ -1108,7 +1098,7 @@ func BenchmarkGetEvents(b *testing.B) { ctx := context.TODO() log := log.DefaultLogger log.SetLevel(logrus.TraceLevel) - store := sqlitedb.NewEventReader(log, dbx, passphrase) + store := sqlitedb.NewEventReader(log, dbx) contractID := xdr.ContractId([32]byte{}) now := time.Now().UTC() @@ -1131,7 +1121,6 @@ func BenchmarkGetEvents(b *testing.B) { dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), } request := protocol.GetEventsRequest{ @@ -1246,7 +1235,7 @@ func setupTwoContractEventsHandler(t *testing.T) (eventsRPCHandler, xdr.Contract write, err := writer.NewTx(ctx) require.NoError(t, err) ledgerW, eventW := write.LedgerWriter(), write.EventWriter() - store := sqlitedb.NewEventReader(logger, dbx, passphrase) + store := sqlitedb.NewEventReader(logger, dbx) txMeta := []xdr.TransactionMeta{ transactionMetaWithEvents( @@ -1274,7 +1263,6 @@ func setupTwoContractEventsHandler(t *testing.T) (eventsRPCHandler, xdr.Contract dbReader: store, maxLimit: 10000, defaultLimit: 100, - ledgerReader: sqlitedb.NewLedgerReader(dbx), }, contractA, contractB } diff --git a/cmd/stellar-rpc/internal/methods/get_transaction.go b/cmd/stellar-rpc/internal/methods/get_transaction.go index 499a15fa9..e180e7911 100644 --- a/cmd/stellar-rpc/internal/methods/get_transaction.go +++ b/cmd/stellar-rpc/internal/methods/get_transaction.go @@ -55,6 +55,10 @@ func GetTransaction( Message: fmt.Sprintf("unable to get ledger range: %v", err), } } + // The cache publishes after the commit, so a just-found tx can be newer than the cached latest. + if getTxErr == nil && tx.Ledger.Sequence > storeRange.LastLedger.Sequence { + storeRange.LastLedger = tx.Ledger + } response := protocol.GetTransactionResponse{ LatestLedger: storeRange.LastLedger.Sequence, diff --git a/cmd/stellar-rpc/internal/rpcv1/daemon/daemon.go b/cmd/stellar-rpc/internal/rpcv1/daemon/daemon.go index 3516975f0..8c72908c7 100644 --- a/cmd/stellar-rpc/internal/rpcv1/daemon/daemon.go +++ b/cmd/stellar-rpc/internal/rpcv1/daemon/daemon.go @@ -400,7 +400,7 @@ func createJSONRPCHandler(cfg *config.Config, logger *supportlog.Entry, daemon * Logger: logger, LedgerReader: sqlitedb.NewLedgerReader(daemon.db), TransactionReader: sqlitedb.NewTransactionReader(logger, daemon.db, cfg.NetworkPassphrase), - EventReader: sqlitedb.NewEventReader(logger, daemon.db, cfg.NetworkPassphrase), + EventReader: sqlitedb.NewEventReader(logger, daemon.db), PreflightGetter: daemon.preflightWorkerPool, DataStoreLedgerReader: dataStoreLedgerReader, }) @@ -540,8 +540,7 @@ func (d *Daemon) mustBackfill(cfg *config.Config, feeWindows *feewindow.FeeWindo d.logger.WithError(err).Fatal("failed to backfill ledgers") } - // Clear the DB cache and fee windows so they re-populate from the database - d.db.ResetCache() + // Reset the fee windows so they re-populate from the database feeWindows.Reset() } diff --git a/cmd/stellar-rpc/internal/rpcv1/jsonrpc.go b/cmd/stellar-rpc/internal/rpcv1/jsonrpc.go index d8edbc809..ef8ac3ab4 100644 --- a/cmd/stellar-rpc/internal/rpcv1/jsonrpc.go +++ b/cmd/stellar-rpc/internal/rpcv1/jsonrpc.go @@ -42,7 +42,7 @@ func NewJSONRPCHandler(cfg *config.Config, params HandlerParams) Handler { DataStoreLedgerReader: params.DataStoreLedgerReader, GetEventsHandler: methods.NewGetEventsHandler(params.Logger, params.EventReader, - cfg.MaxEventsLimit, cfg.DefaultEventsLimit, params.LedgerReader), + cfg.MaxEventsLimit, cfg.DefaultEventsLimit), NetworkPassphrase: cfg.NetworkPassphrase, FriendbotURL: cfg.FriendbotURL, diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/db.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/db.go index db0f96441..f9b127963 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/db.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/db.go @@ -51,14 +51,7 @@ type dbCache struct { latestLedgerSeq uint32 latestLedgerCloseTime int64 - // firstLedgerSeq/firstLedgerCloseTime cache the oldest retained ledger's - // range scalars. Without this, GetLedgerRange decodes the entire oldest - // LedgerCloseMeta blob on every call (e.g. on every getTransaction) just to - // read a sequence + close time. A value of 0 means "unknown" -- it is - // populated lazily on the first GetLedgerRange after a reset or after the - // cached oldest ledger has been trimmed away (see Commit), so the expensive - // oldest-ledger decode happens at most once per trim (~once per ledger - // once retention is full) instead of once per read. + // Primed at open and republished by every commit; a zero latest means an empty DB. firstLedgerSeq uint32 firstLedgerCloseTime int64 } @@ -69,15 +62,6 @@ type DB struct { cache *dbCache } -func (d *DB) ResetCache() { - d.cache.Lock() - defer d.cache.Unlock() - d.cache.latestLedgerSeq = 0 - d.cache.latestLedgerCloseTime = 0 - d.cache.firstLedgerSeq = 0 - d.cache.firstLedgerCloseTime = 0 -} - const ( serveSQLitePragmas = "_journal_mode=WAL&_synchronous=NORMAL" indexBuildSQLitePragmas = serveSQLitePragmas + "&_cache_size=-32768" // size CREATE INDEX sorter's memory budget @@ -240,11 +224,7 @@ func OpenSQLiteDBWithPrometheusMetrics(dbFilePath string, namespace string, sub if err != nil { return nil, err } - result := DB{ - SessionInterface: db.RegisterMetrics(session, namespace, sub, registry), - cache: &dbCache{}, - } - return &result, nil + return newDB(db.RegisterMetrics(session, namespace, sub, registry)) } func OpenSQLiteDB(dbFilePath string) (*DB, error) { @@ -252,11 +232,22 @@ func OpenSQLiteDB(dbFilePath string) (*DB, error) { if err != nil { return nil, err } - result := DB{ - SessionInterface: session, - cache: &dbCache{}, + return newDB(session) +} + +// newDB primes the range cache from the stored bounds; a fresh DB waits for its first commit. +func newDB(session db.SessionInterface) (*DB, error) { + d := &DB{SessionInterface: session, cache: &dbCache{}} + lr, err := getLedgerRangeWithoutCache(context.Background(), session) + switch { + case errors.Is(err, store.ErrEmptyDB): + case err != nil: + return nil, errors.Join(err, session.Close()) + default: + d.cache.firstLedgerSeq, d.cache.firstLedgerCloseTime = lr.FirstLedger.Sequence, lr.FirstLedger.CloseTime + d.cache.latestLedgerSeq, d.cache.latestLedgerCloseTime = lr.LastLedger.Sequence, lr.LastLedger.CloseTime } - return &result, nil + return d, nil } func getMetaBool(ctx context.Context, q db.SessionInterface, key string) (bool, error) { @@ -292,34 +283,6 @@ func getMetaValue(ctx context.Context, q db.SessionInterface, key string) (strin return results[0], nil } -func getLatestLedgerSequence(ctx context.Context, ledgerReader LedgerReader, cache *dbCache) (uint32, error) { - cache.RLock() - latestLedgerSeqCache := cache.latestLedgerSeq - cache.RUnlock() - - if latestLedgerSeqCache != 0 { - return latestLedgerSeqCache, nil - } - - ledgerRange, err := ledgerReader.GetLedgerRange(ctx) - if err != nil { - return 0, err - } - - // Add missing ledger sequence and close time to the top cache. - // Otherwise, the write-through cache won't get updated until the first ingestion commit - cache.Lock() - if cache.latestLedgerSeq < ledgerRange.LastLedger.Sequence { - // Only update the cache if the value is missing (0), otherwise - // we may end up overwriting the entry with an older version - cache.latestLedgerSeq = ledgerRange.LastLedger.Sequence - cache.latestLedgerCloseTime = ledgerRange.LastLedger.CloseTime - } - cache.Unlock() - - return ledgerRange.LastLedger.Sequence, nil -} - type ReadWriterMetrics struct { TxIngestDuration, TxCount prometheus.Observer } @@ -374,7 +337,7 @@ func NewReadWriter( } func (rw *readWriter) GetLatestLedgerSequence(ctx context.Context) (uint32, error) { - return getLatestLedgerSequence(ctx, NewLedgerReader(rw.db), rw.db.cache) + return NewLedgerReader(rw.db).GetLatestLedgerSequence(ctx) } func (rw *readWriter) NewTx(ctx context.Context) (WriteTx, error) { @@ -387,6 +350,9 @@ func (rw *readWriter) NewTx(ctx context.Context) (WriteTx, error) { db := rw.db writer := writeTx{ globalCache: db.cache, + oldestLedger: func() (store.LedgerInfo, error) { + return oldestLedgerInfo(ctx, txSession) + }, postCommit: func(durationMetrics map[string]time.Duration) error { // TODO: this is sqlite-only, it shouldn't be here startTime := time.Now() @@ -426,6 +392,7 @@ func (rw *readWriter) NewTx(ctx context.Context) (WriteTx, error) { type writeTx struct { globalCache *dbCache + oldestLedger func() (store.LedgerInfo, error) // reads inside the write tx postCommit func(durationMetrics map[string]time.Duration) error tx db.SessionInterface stmtCache *sq.StmtCache @@ -459,42 +426,35 @@ func (w writeTx) Commit(ledgerCloseMeta xdr.LedgerCloseMeta, durationMetrics map return err } - // We need to make the cache update atomic with the transaction commit. - // Otherwise, the cache can be made inconsistent if a write transaction finishes - // in between, updating the cache in the wrong order. - commitAndUpdateCache := func() error { - w.globalCache.Lock() - defer w.globalCache.Unlock() - if err := w.tx.Commit(); err != nil { - return err - } - if ledgerSeq > w.globalCache.latestLedgerSeq { - w.globalCache.latestLedgerSeq = ledgerSeq - w.globalCache.latestLedgerCloseTime = ledgerCloseTime - } - // Invalidate the cached oldest-ledger scalars when trimLedgers (run - // above with this same retention window) has removed the ledger they - // describe. cutoff mirrors trimLedgers: rows with sequence < cutoff are - // deleted. Only invalidate when retention is actually trimming and the - // cached oldest was at/below the cutoff, so the lazy recompute happens - // at most once per trim rather than on every read. - if w.historyRetentionWindow != 0 && ledgerSeq+1 > w.historyRetentionWindow { - cutoff := ledgerSeq + 1 - w.historyRetentionWindow - if w.globalCache.firstLedgerSeq != 0 && w.globalCache.firstLedgerSeq < cutoff { - w.globalCache.firstLedgerSeq = 0 - w.globalCache.firstLedgerCloseTime = 0 - } - } - return nil - } + // Published only after the commit, so the cache holds committed bounds and the + // commit never holds the lock; snapshot readers validate inside their own tx. + // One writer at a time (the ingest loop), so publish order is commit order. startTime := time.Now() - if err := commitAndUpdateCache(); err != nil { + oldest, err := w.oldestLedger() + if err != nil { + return err + } + if durationMetrics != nil { + durationMetrics["oldest_ledger"] = time.Since(startTime) + } + + startTime = time.Now() + if err := w.tx.Commit(); err != nil { return err } if durationMetrics != nil { durationMetrics["commit"] = time.Since(startTime) } + w.globalCache.Lock() + w.globalCache.firstLedgerSeq = oldest.Sequence + w.globalCache.firstLedgerCloseTime = oldest.CloseTime + if ledgerSeq > w.globalCache.latestLedgerSeq { + w.globalCache.latestLedgerSeq = ledgerSeq + w.globalCache.latestLedgerCloseTime = ledgerCloseTime + } + w.globalCache.Unlock() + return w.postCommit(durationMetrics) } diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go index 1e319c12b..d1b63b714 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event.go @@ -1,4 +1,3 @@ -//nolint:funcorder // event reader/writer helpers are grouped for readability package sqlitedb import ( @@ -48,8 +47,35 @@ type dbEvent struct { // 10 bind variables/event * 3,000 events stays under SQLite's 32,766 limit const maxEventsPerBatch = 3000 -func NewEventReader(log *log.Entry, db db.SessionInterface, passphrase string) store.EventReader { - return &eventHandler{log: log, db: db, passphrase: passphrase} +// eventScanner runs the getEvents query against one readDB: the pool or a snapshot. +type eventScanner struct { + log *log.Entry + q readDB +} + +// eventReader is the pooled read side; NewTx pins a snapshot. +type eventReader struct { + eventScanner + + db *DB +} + +func NewEventReader(log *log.Entry, db *DB) store.EventReader { + return eventReader{eventScanner: eventScanner{log: log, q: db}, db: db} +} + +// NewTx opens a read snapshot whose GetLedgerRange and GetEvents agree. +func (r eventReader) NewTx(ctx context.Context) (store.EventReaderTx, error) { + tx, err := newLedgerReaderTx(ctx, r.db) + if err != nil { + return nil, err + } + return eventReaderTx{ledgerReaderTx: tx, eventScanner: eventScanner{log: r.log, q: tx.tx}}, nil +} + +type eventReaderTx struct { + ledgerReaderTx + eventScanner } // Named error return: the deferred txReader.Close errors.Join below @@ -295,7 +321,7 @@ func (eventHandler *eventHandler) trimEvents(latestLedgerSeq uint32, retentionWi // remaining events in the range). If f returns an error, the scan aborts with it. // //nolint:funlen,cyclop -func (eventHandler *eventHandler) GetEvents( +func (s eventScanner) GetEvents( ctx context.Context, cursorRange protocol.CursorRange, contractIDs [][]byte, @@ -352,9 +378,9 @@ func (eventHandler *eventHandler) GetEvents( encodedContractIDs = append(encodedContractIDs, result) } - rows, err := eventHandler.db.Query(ctx, rowQ) + rows, err := s.q.Query(ctx, rowQ) if err != nil { - eventHandler.log. + s.log. WithError(err). WithField("duration", time.Since(start)). WithField("start", cursorRange.Start.String()). @@ -409,7 +435,7 @@ func (eventHandler *eventHandler) GetEvents( foundRows++ } - eventHandler.log. + s.log. WithField("duration", time.Since(start)). WithField("start", cursorRange.Start.String()). WithField("end", cursorRange.End.String()). diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go index 26ccaa3bc..3e602bcae 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/event_test.go @@ -15,6 +15,7 @@ import ( "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/host" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/store" ) func transactionMetaWithEvents(events ...xdr.ContractEvent) xdr.TransactionMeta { @@ -204,7 +205,7 @@ func TestInsertEvents(t *testing.T) { err = eventW.InsertEvents(ledgerCloseMeta) require.NoError(t, err) - eventReader := NewEventReader(log, db, passphrase) + eventReader := NewEventReader(log, db) start := protocol.Cursor{Ledger: 1} end := protocol.Cursor{Ledger: 100} cursorRange := protocol.CursorRange{Start: start, End: end} @@ -213,6 +214,52 @@ func TestInsertEvents(t *testing.T) { require.NoError(t, err) } +// A snapshot keeps serving the event it validated after a trim commits underneath it. +func TestEventReaderTx_SurvivesTrim(t *testing.T) { + db := NewTestDB(t) + ctx := t.Context() + counter := xdr.ScSymbol("COUNTER") + sym := xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &counter} + event := contractEvent(xdr.ContractId([32]byte{}), xdr.ScVec{sym}, sym) + + write, err := NewReadWriter(log.DefaultLogger, db, host.MakeNoOpDaemon(), 10, passphrase).NewTx(ctx) + require.NoError(t, err) + var last xdr.LedgerCloseMeta + for seq := uint32(1); seq <= 2; seq++ { + last = ledgerCloseMetaWithEvents(seq, time.Now().Unix(), transactionMetaWithEvents(event)) + require.NoError(t, write.LedgerWriter().InsertLedger(last)) + require.NoError(t, write.EventWriter().InsertEvents(last)) + } + require.NoError(t, write.Commit(last, nil)) + + count := func(scanner store.EventScanner) int { + n := 0 + window := protocol.CursorRange{Start: protocol.Cursor{Ledger: 1}, End: protocol.Cursor{Ledger: 2}} + require.NoError(t, scanner.GetEvents(ctx, window, nil, nil, nil, + func(xdr.DiagnosticEventView, protocol.Cursor, int64, *xdr.Hash) (bool, error) { n++; return true, nil })) + return n + } + pool := NewEventReader(log.DefaultLogger, db) + before := count(pool) + require.NotZero(t, before) + + snap, err := pool.NewTx(ctx) + require.NoError(t, err) + defer func() { require.NoError(t, snap.Done()) }() + _, err = snap.GetLedgerRange(ctx) // the first statement is what pins the snapshot + require.NoError(t, err) + + // Trim ledger 1 with the predicates trimLedgers/trimEvents use; a full Commit's + // TRUNCATE checkpoint would wait out the busy timeout on the open snapshot. + _, err = db.ExecRaw(ctx, "DELETE FROM events WHERE id < ?", protocol.Cursor{Ledger: 2}.String()) + require.NoError(t, err) + _, err = db.ExecRaw(ctx, "DELETE FROM ledger_close_meta WHERE sequence < 2") + require.NoError(t, err) + + require.Equal(t, 0, count(pool)) + require.Equal(t, before, count(snap)) +} + func TestInsertEventsBatchingExceedsLimit(t *testing.T) { ctx := t.Context() log := log.DefaultLogger @@ -236,7 +283,7 @@ func TestInsertEventsBatchingExceedsLimit(t *testing.T) { for i, tc := range tests { t.Run(tc.name, func(t *testing.T) { - testDB := NewTestDB(t) + testDB := NewTestDB(t) //nolint:contextcheck // the test DB opener has no ctx to take opEvents := make([]xdr.ContractEvent, 0, tc.numOpEvents) for range tc.numOpEvents { @@ -269,7 +316,7 @@ func TestInsertEventsBatchingExceedsLimit(t *testing.T) { require.NoError(t, write.Commit(lcm, nil)) - eventReader := NewEventReader(log, testDB, passphrase) + eventReader := NewEventReader(log, testDB) start := protocol.Cursor{Ledger: ledgerSeq} end := protocol.Cursor{Ledger: ledgerSeq + 1} cursorRange := protocol.CursorRange{Start: start, End: end} diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go index 59342ba22..c406ba400 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger.go @@ -32,6 +32,7 @@ type LedgerWriter interface { type readDB interface { Select(ctx context.Context, dest any, query sq.Sqlizer) error + GetRaw(ctx context.Context, dest any, query string, args ...any) error Query(ctx context.Context, query sq.Sqlizer) (*db.Rows, error) } @@ -40,16 +41,19 @@ type ledgerReader struct { } type ledgerReaderTx struct { - tx db.SessionInterface - latestLedgerSeq uint32 - latestLedgerCloseTime int64 + tx db.SessionInterface + cached store.LedgerRange // bounds at NewTx; its close times are reused when the snapshot agrees } func (l ledgerReaderTx) GetLedgerRange(ctx context.Context) (store.LedgerRange, error) { - if l.latestLedgerSeq != 0 { - return getLedgerRangeWithCache(ctx, l.tx, l.latestLedgerSeq, l.latestLedgerCloseTime) + first, last, err := snapshotBounds(ctx, l.tx) + if err != nil { + return store.LedgerRange{}, err + } + if first == l.cached.FirstLedger.Sequence && last == l.cached.LastLedger.Sequence { + return l.cached, nil } - return getLedgerRangeWithoutCache(ctx, l.tx) + return getLedgerRangeWithoutCache(ctx, l.tx) // a commit landed between the cache copy and this statement } // ScanLedgers reads inside the reader's transaction. @@ -66,20 +70,27 @@ func NewLedgerReader(db *DB) LedgerReader { } func (r ledgerReader) NewTx(ctx context.Context) (store.LedgerReaderTx, error) { - r.db.cache.RLock() - defer r.db.cache.RUnlock() - txSession := r.db.Clone() - if err := txSession.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { - return nil, fmt.Errorf("failed to begin read transaction: %w", err) - } - tx := ledgerReaderTx{ - tx: txSession, - latestLedgerSeq: r.db.cache.latestLedgerSeq, - latestLedgerCloseTime: r.db.cache.latestLedgerCloseTime, + tx, err := newLedgerReaderTx(ctx, r.db) + if err != nil { + return nil, err } return tx, nil } +// newLedgerReaderTx opens a read snapshot and copies the cached bounds for their close times. +func newLedgerReaderTx(ctx context.Context, db *DB) (ledgerReaderTx, error) { + txSession := db.Clone() + if err := txSession.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return ledgerReaderTx{}, fmt.Errorf("failed to begin read transaction: %w", err) + } + db.cache.RLock() + defer db.cache.RUnlock() + return ledgerReaderTx{tx: txSession, cached: store.LedgerRange{ + FirstLedger: store.LedgerInfo{Sequence: db.cache.firstLedgerSeq, CloseTime: db.cache.firstLedgerCloseTime}, + LastLedger: store.LedgerInfo{Sequence: db.cache.latestLedgerSeq, CloseTime: db.cache.latestLedgerCloseTime}, + }}, nil +} + // ScanLedgers reads the pooled connection: no snapshot, the store as it stands. func (r ledgerReader) ScanLedgers(ctx context.Context, start, end uint32) iter.Seq2[store.RawLedger, error] { return scanLedgers(ctx, r.db, start, end) @@ -125,56 +136,17 @@ func scanLedgers(ctx context.Context, q readDB, start, end uint32) iter.Seq2[sto } } -// GetLedgerRange pulls the min/max ledger sequence numbers from the meta table. -func (r ledgerReader) GetLedgerRange(ctx context.Context) (store.LedgerRange, error) { +// GetLedgerRange returns the cached bounds; a zero latest means the DB is empty. +func (r ledgerReader) GetLedgerRange(_ context.Context) (store.LedgerRange, error) { r.db.cache.RLock() - latestLedgerSeqCache := r.db.cache.latestLedgerSeq - latestLedgerCloseTimeCache := r.db.cache.latestLedgerCloseTime - firstLedgerSeqCache := r.db.cache.firstLedgerSeq - firstLedgerCloseTimeCache := r.db.cache.firstLedgerCloseTime - r.db.cache.RUnlock() - - // Fully cached: both ends known, no query at all. This is the hot path for - // read-heavy workloads (e.g. getTransaction polling), which previously - // decoded the entire oldest LedgerCloseMeta blob on every single call. - if latestLedgerSeqCache != 0 && firstLedgerSeqCache != 0 { - return store.LedgerRange{ - FirstLedger: store.LedgerInfo{ - Sequence: firstLedgerSeqCache, - CloseTime: firstLedgerCloseTimeCache, - }, - LastLedger: store.LedgerInfo{ - Sequence: latestLedgerSeqCache, - CloseTime: latestLedgerCloseTimeCache, - }, - }, nil - } - - // Latest cached but oldest unknown (startup, or invalidated by a trim): - // decode the oldest ledger once, then memoize its scalars so subsequent - // reads take the fully-cached path above until the next trim. - if latestLedgerSeqCache != 0 { - ledgerRange, err := getLedgerRangeWithCache(ctx, r.db, latestLedgerSeqCache, latestLedgerCloseTimeCache) - if err != nil { - return ledgerRange, err - } - r.db.cache.Lock() - // Only memoize the oldest if no commit advanced the latest ledger since - // we read it above. A trim runs inside a commit and always advances - // latest, so an unchanged latest proves no trim raced our MIN(sequence) - // query -- otherwise the trim could have removed the very ledger we just - // read, and caching it would report a trimmed ledger as the oldest until - // the next commit's invalidation. The returned range is still correct as - // of the query; we just decline to persist a possibly-stale oldest and - // let the next call recompute. - if r.db.cache.firstLedgerSeq == 0 && r.db.cache.latestLedgerSeq == latestLedgerSeqCache { - r.db.cache.firstLedgerSeq = ledgerRange.FirstLedger.Sequence - r.db.cache.firstLedgerCloseTime = ledgerRange.FirstLedger.CloseTime - } - r.db.cache.Unlock() - return ledgerRange, nil + defer r.db.cache.RUnlock() + if r.db.cache.latestLedgerSeq == 0 { + return store.LedgerRange{}, store.ErrEmptyDB } - return getLedgerRangeWithoutCache(ctx, r.db) + return store.LedgerRange{ + FirstLedger: store.LedgerInfo{Sequence: r.db.cache.firstLedgerSeq, CloseTime: r.db.cache.firstLedgerCloseTime}, + LastLedger: store.LedgerInfo{Sequence: r.db.cache.latestLedgerSeq, CloseTime: r.db.cache.latestLedgerCloseTime}, + }, nil } func (r ledgerReader) GetLedgerCountInRange(ctx context.Context, start, end uint32) (uint32, uint32, uint32, error) { @@ -182,7 +154,8 @@ func (r ledgerReader) GetLedgerCountInRange(ctx context.Context, start, end uint } func (r ledgerReader) GetLatestLedgerSequence(ctx context.Context) (uint32, error) { - return getLatestLedgerSequence(ctx, r, r.db.cache) + ledgerRange, err := r.GetLedgerRange(ctx) + return ledgerRange.LastLedger.Sequence, err } // ledgerCloseTimePrefixBytes is the fast-path meta prefix fetched for range @@ -214,11 +187,8 @@ func ledgerInfoFromRow(ctx context.Context, db readDB, row ledgerRangeRow) (stor 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) { +// oldestLedgerInfo reads the oldest stored ledger's range scalars (a write tx sees its own trims). +func oldestLedgerInfo(ctx context.Context, db readDB) (store.LedgerInfo, error) { query := sq.Select("sequence", fmt.Sprintf("substr(meta, 1, %d) AS meta_prefix", ledgerCloseTimePrefixBytes)). From(ledgerCloseMetaTableName). Where( @@ -226,24 +196,31 @@ func getLedgerRangeWithCache(ctx context.Context, db readDB, ) var rows []ledgerRangeRow if err := db.Select(ctx, &rows, query); err != nil { - return store.LedgerRange{}, fmt.Errorf("couldn't query ledger range: %w", err) + return store.LedgerInfo{}, fmt.Errorf("couldn't query ledger range: %w", err) } - 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.LedgerInfo{}, store.ErrEmptyDB } + return ledgerInfoFromRow(ctx, db, rows[0]) +} - return store.LedgerRange{ - FirstLedger: firstLedger, - LastLedger: store.LedgerInfo{ - Sequence: latestSeq, - CloseTime: latestTime, - }, - }, nil +// Two scalar subqueries are two index probes; a single SELECT MIN(), MAX() is a full scan. +const snapshotBoundsSQL = "SELECT (SELECT MIN(sequence) FROM " + ledgerCloseMetaTableName + ") AS min_seq," + + " (SELECT MAX(sequence) FROM " + ledgerCloseMetaTableName + ") AS max_seq" + +// snapshotBounds is the snapshot's oldest and latest sequence, without close times. +func snapshotBounds(ctx context.Context, db readDB) (uint32, uint32, error) { + var bounds struct { + Min sql.Null[uint32] `db:"min_seq"` + Max sql.Null[uint32] `db:"max_seq"` + } + if err := db.GetRaw(ctx, &bounds, snapshotBoundsSQL); err != nil { + return 0, 0, fmt.Errorf("couldn't query ledger bounds: %w", err) + } + if !bounds.Min.Valid { + return 0, 0, store.ErrEmptyDB + } + return bounds.Min.V, bounds.Max.V, nil } // getLedgerRangeWithoutCache queries both the first and last ledger when cache isn't available diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go index 31b412320..d25381efc 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/ledger_test.go @@ -189,12 +189,10 @@ func TestGetLedgerRange_SingleDBRow(t *testing.T) { assert.Equal(t, ledgerCloseTime(1334), ledgerRange.LastLedger.CloseTime) } -// TestGetLedgerRange_OldestCacheInvalidatedOnTrim verifies that the cached -// oldest-ledger scalars are refreshed once the retention window trims the -// ledger they describe -- so GetLedgerRange keeps reporting the true oldest -// ledger rather than a stale cached one, while still avoiding the per-call -// oldest-ledger decode in steady state. -func TestGetLedgerRange_OldestCacheInvalidatedOnTrim(t *testing.T) { +// TestGetLedgerRange_OldestCachePublishedOnTrim verifies that a trimming +// commit itself publishes the new oldest ledger's scalars, so GetLedgerRange +// reports the true oldest without ever decoding it on the read path. +func TestGetLedgerRange_OldestCachePublishedOnTrim(t *testing.T) { const retentionWindow = 10 db := NewTestDB(t) ctx := context.TODO() @@ -215,9 +213,18 @@ func TestGetLedgerRange_OldestCacheInvalidatedOnTrim(t *testing.T) { require.NoError(t, write.Commit(last, nil)) } - // Phase 1: ingest exactly the retention window (sequences 1334..1343); no - // trimming yet, oldest = 1334. The read populates the oldest cache. + cachedOldest := func() (uint32, int64) { + db.cache.RLock() + defer db.cache.RUnlock() + return db.cache.firstLedgerSeq, db.cache.firstLedgerCloseTime + } + + // Phase 1: ingest exactly the retention window (sequences 1334..1343); the + // trim removes nothing, and the commit publishes oldest = 1334. ingest(1234, retentionWindow) + seq, closeTime := cachedOldest() + assert.Equal(t, uint32(1334), seq) + assert.Equal(t, ledgerCloseTime(1334), closeTime) ledgerRange, err := reader.GetLedgerRange(ctx) require.NoError(t, err) assert.Equal(t, uint32(1334), ledgerRange.FirstLedger.Sequence) @@ -226,9 +233,11 @@ func TestGetLedgerRange_OldestCacheInvalidatedOnTrim(t *testing.T) { // Phase 2: ingest 5 more (sequences 1344..1348). With retention 10 and // latest 1348, the cutoff is 1339, trimming 1334..1338 -- which includes the - // cached oldest (1334), so the cache must invalidate and the next read must - // report the new oldest (1339), not the stale 1334. + // cached oldest (1334), so the commit must publish 1339 before any read. ingest(1244, 5) + seq, closeTime = cachedOldest() + assert.Equal(t, uint32(1339), seq) + assert.Equal(t, ledgerCloseTime(1339), closeTime) ledgerRange, err = reader.GetLedgerRange(ctx) require.NoError(t, err) assert.Equal(t, uint32(1339), ledgerRange.FirstLedger.Sequence) @@ -237,6 +246,62 @@ func TestGetLedgerRange_OldestCacheInvalidatedOnTrim(t *testing.T) { assert.Equal(t, ledgerCloseTime(1348), ledgerRange.LastLedger.CloseTime) } +// The snapshot reader takes both bounds from its own tx; the cached pair only supplies close times. +func TestLedgerReaderTx_GetLedgerRange_FromSnapshot(t *testing.T) { + db := NewTestDB(t) + ctx := context.TODO() + write, err := NewReadWriter(logger, db, host.MakeNoOpDaemon(), 10, passphrase).NewTx(ctx) + require.NoError(t, err) + for seq := uint32(1234); seq <= 1236; seq++ { + require.NoError(t, write.LedgerWriter().InsertLedger(txMeta(seq, true))) + } + require.NoError(t, write.Commit(txMeta(1236, true), nil)) + + reader := NewLedgerReader(db) + for _, tc := range []struct { + cachedFirst, cachedLast uint32 + wantFirstCT, wantLastCT int64 + }{ + {1334, 1336, 41, 43}, // agrees: the cached (sentinel) close times are returned + {0, 0, ledgerCloseTime(1334), ledgerCloseTime(1336)}, // unprimed + {1333, 1335, ledgerCloseTime(1334), ledgerCloseTime(1336)}, // lagging a commit + } { + db.cache.Lock() + db.cache.firstLedgerSeq, db.cache.latestLedgerSeq = tc.cachedFirst, tc.cachedLast + db.cache.firstLedgerCloseTime, db.cache.latestLedgerCloseTime = 41, 43 + db.cache.Unlock() + tx, err := reader.NewTx(ctx) + require.NoError(t, err) + ledgerRange, err := tx.GetLedgerRange(ctx) + require.NoError(t, tx.Done()) + require.NoError(t, err) + assert.Equal(t, store.LedgerInfo{Sequence: 1334, CloseTime: tc.wantFirstCT}, ledgerRange.FirstLedger) + assert.Equal(t, store.LedgerInfo{Sequence: 1336, CloseTime: tc.wantLastCT}, ledgerRange.LastLedger) + } +} + +// Reopening a populated DB primes the cache without waiting for a commit. +func TestOpenSQLiteDB_PrimesLedgerRange(t *testing.T) { + ctx := context.TODO() + dbPath := path.Join(t.TempDir(), "db.sqlite") + db, err := OpenSQLiteDB(dbPath) + require.NoError(t, err) + write, err := NewReadWriter(logger, db, host.MakeNoOpDaemon(), 10, passphrase).NewTx(ctx) + require.NoError(t, err) + require.NoError(t, write.LedgerWriter().InsertLedger(txMeta(1234, true))) + require.NoError(t, write.LedgerWriter().InsertLedger(txMeta(1235, true))) + require.NoError(t, write.Commit(txMeta(1235, true), nil)) + require.NoError(t, db.Close()) + + db, err = OpenSQLiteDB(dbPath) + require.NoError(t, err) + defer func() { require.NoError(t, db.Close()) }() + ledgerRange, err := NewLedgerReader(db).GetLedgerRange(ctx) + require.NoError(t, err) + assert.Equal(t, store.LedgerInfo{Sequence: 1334, CloseTime: ledgerCloseTime(1334)}, ledgerRange.FirstLedger) + assert.Equal(t, store.LedgerInfo{Sequence: 1335, CloseTime: ledgerCloseTime(1335)}, ledgerRange.LastLedger) +} + func TestGetLedgerRange_EmptyDB(t *testing.T) { db := NewTestDB(t) ctx := context.TODO() @@ -373,8 +438,8 @@ func padLedger(lcm xdr.LedgerCloseMeta, size int) xdr.LedgerCloseMeta { } // BenchmarkOldestLedgerRangeLookup measures the 1KiB prefix fetch in -// getLedgerRangeWithCache. The tx read path (getLedgers/getTransactions) runs -// this lookup once per request. +// oldestLedgerInfo: every commit runs it, and a snapshot reader only when the +// cached bounds disagree with its own. func BenchmarkOldestLedgerRangeLookup(b *testing.B) { for _, tc := range []struct { name string @@ -402,16 +467,15 @@ func BenchmarkOldestLedgerRangeLookup(b *testing.B) { } 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) + got, err := oldestLedgerInfo(ctx, testDB) require.NoError(b, err) - require.Equal(b, lcms[0].LedgerSequence(), got.FirstLedger.Sequence) + require.Equal(b, lcms[0].LedgerSequence(), got.Sequence) b.Run(tc.name, func(b *testing.B) { b.ReportAllocs() for b.Loop() { - _, err := getLedgerRangeWithCache(ctx, testDB, latestSeq, latestTime) + _, err := oldestLedgerInfo(ctx, testDB) require.NoError(b, err) } }) diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go index b990ce7c9..9e5997526 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/migration_test.go @@ -76,7 +76,7 @@ func requireLedgerData(t *testing.T, testDB *DB, lcms []xdr.LedgerCloseMeta) { } eventCount := 0 - eventReader := NewEventReader(log.DefaultLogger, testDB, passphrase) + eventReader := NewEventReader(log.DefaultLogger, testDB) cursorRange := protocol.CursorRange{ Start: protocol.Cursor{Ledger: lcms[0].LedgerSequence()}, End: protocol.Cursor{Ledger: lcms[len(lcms)-1].LedgerSequence() + 1}, diff --git a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction_test.go b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction_test.go index 2dc94f43c..134b3f8aa 100644 --- a/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction_test.go +++ b/cmd/stellar-rpc/internal/rpcv1/sqlitedb/transaction_test.go @@ -225,7 +225,7 @@ func TestTransactionFound(t *testing.T) { _, err = reader.GetTransaction(ctx, xdr.Hash{}) require.ErrorIs(t, err, store.ErrNoTransaction) - eventReader := NewEventReader(log, db, passphrase) + eventReader := NewEventReader(log, db) start := protocol.Cursor{Ledger: 1} end := protocol.Cursor{Ledger: 1000} cursorRange := protocol.CursorRange{Start: start, End: end} @@ -265,7 +265,7 @@ func TestInsertTransactionsBatchingExceedsLimit(t *testing.T) { for i, tc := range tests { t.Run(tc.name, func(t *testing.T) { - testDB := NewTestDB(t) + testDB := NewTestDB(t) //nolint:contextcheck // the test DB opener has no ctx to take ledgerSeq := uint32(10 + i) lcm := lcmWithCtTxns(ledgerSeq, tc.numTxs) diff --git a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_parity_test.go b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_parity_test.go index e98a11ce2..6825bf1c8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_parity_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/eventsapi/v1_parity_test.go @@ -181,9 +181,8 @@ func newV1Client(t *testing.T, lcms [][]byte) *jrpc2.Client { require.NoError(t, wtx.Commit(lcm, map[string]time.Duration{})) } h := methods.NewGetEventsHandler(logger, - sqlitedb.NewEventReader(logger, db, network.PublicNetworkPassphrase), - parityMaxLimit, parityDefaultLimit, - sqlitedb.NewLedgerReader(db)) + sqlitedb.NewEventReader(logger, db), + parityMaxLimit, parityDefaultLimit) return newLocalClient(t, h) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go index b398c8d05..b92b538df 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/extract_test.go @@ -597,7 +597,7 @@ func sqliteEventRows(t *testing.T, lcm xdr.LedgerCloseMeta) []sqliteEventRow { End: protocol.Cursor{Ledger: seq + 1}, } var rows []sqliteEventRow - reader := sqlitedb.NewEventReader(logger, testDB, testPassphrase) + reader := sqlitedb.NewEventReader(logger, testDB) err = reader.GetEvents(ctx, cursorRange, nil, nil, nil, func(eventView xdr.DiagnosticEventView, cur protocol.Cursor, closeTime int64, txHash *xdr.Hash) (bool, error) { ev, verr := eventView.Event() diff --git a/cmd/stellar-rpc/internal/store/event.go b/cmd/stellar-rpc/internal/store/event.go index 9feaa008a..b05ff4af7 100644 --- a/cmd/stellar-rpc/internal/store/event.go +++ b/cmd/stellar-rpc/internal/store/event.go @@ -34,8 +34,8 @@ type ViewScanFunction func( txHash *xdr.Hash, ) (bool, error) -// EventReader has all the public methods to fetch events from the backend. -type EventReader interface { +// EventScanner is the one read idiom, embedded by EventReader and EventReaderTx. +type EventScanner interface { GetEvents( ctx context.Context, cursorRange protocol.CursorRange, @@ -46,6 +46,19 @@ type EventReader interface { ) error } +// EventReader has all the public methods to fetch events from the backend. +type EventReader interface { + EventScanner + NewTx(ctx context.Context) (EventReaderTx, error) +} + +// EventReaderTx is a LedgerReaderTx snapshot that also scans events, so a cursor +// validated against its range cannot be trimmed away before the scan. +type EventReaderTx interface { + LedgerReaderTx + EventScanner +} + // StageSentinels maps a V4 top-level TransactionEvent stage to the // (TxIdx, OpIdx) sentinels the v1 getEvents cursor encoding uses. applyIdx is // the 1-based transaction apply index (consumed by the AfterTx arm). This is