faraday/chanevents/store.go
bitromortac dccbf53a17
chanevents: add channel event pruning
We add regular channel event pruning, as otherwise the database may get
filled quickly. We add two mechanisms, a retention time and a max events
number. Both can be turned on individually.
2026-06-25 13:53:26 +02:00

424 lines
11 KiB
Go

package chanevents
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/db/sqlc"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/sqldb/v2"
)
var (
errUnknownPeer = errors.New("unknown peer")
// ErrUnknownChannel signals that the requested channel is not
// present in the store.
ErrUnknownChannel = errors.New("unknown channel")
)
// Queries is a subset of the sqlc.Queries interface that can be used to
// interact with the peers, channels and channel_events tables.
type Queries interface {
InsertPeer(ctx context.Context, pubkey string) (int64, error)
GetPeerByPubKey(ctx context.Context, pubkey string) (sqlc.Peer, error)
InsertChannel(ctx context.Context,
arg sqlc.InsertChannelParams) (int64, error)
GetChannelByChanPoint(ctx context.Context,
channelPoint string) (sqlc.Channel, error)
GetChannelByShortChanID(ctx context.Context,
shortChannelID int64) (sqlc.Channel, error)
InsertChannelEvent(ctx context.Context,
arg sqlc.InsertChannelEventParams) error
GetChannelEvents(ctx context.Context,
arg sqlc.GetChannelEventsParams) ([]sqlc.ChannelEvent, error)
GetLatestChannelEventBefore(ctx context.Context,
arg sqlc.GetLatestChannelEventBeforeParams) (
sqlc.ChannelEvent,
error,
)
GetChannels(ctx context.Context) ([]sqlc.GetChannelsRow, error)
PruneChannelEventsBySize(ctx context.Context, offset int32) (int64,
error)
PruneChannelEventsByAge(ctx context.Context, timestamp time.Time) (
int64, error)
}
// Store provides access to the db for channel events.
type Store struct {
// db is all the higher level queries that the SQLStore has access to in
// order to implement all its CRUD logic.
db BatchedSQLQueries
// BaseDB represents the underlying database connection.
*sqldb.BaseDB
clock clock.Clock
}
// BatchedSQLQueries combines the SQLQueries interface with the BatchedTx
// interface, allowing for multiple queries to be executed in single SQL
// transaction.
type BatchedSQLQueries interface {
SQLQueries
sqldb.BatchedTx[SQLQueries]
}
// SQLQueries is a subset of the sqlc.Queries interface that can be used to
// interact with various chanevents tables.
type SQLQueries interface {
sqldb.BaseQuerier
Queries
}
type SQLQueriesExecutor[T sqldb.BaseQuerier] struct {
*sqldb.TransactionExecutor[T]
SQLQueries
}
// NewStore creates a new SQLStore instance given an open SQLQueries storage
// backend.
func NewStore(sqlDB *sqldb.BaseDB, queries *sqlc.Queries,
clock clock.Clock) *Store {
txExecutor := sqldb.NewTransactionExecutor(
sqlDB,
func(tx *sql.Tx) SQLQueries {
return queries.WithTx(tx)
},
)
executor := &SQLQueriesExecutor[SQLQueries]{
TransactionExecutor: txExecutor,
SQLQueries: queries,
}
return &Store{
db: executor,
BaseDB: sqlDB,
clock: clock,
}
}
// AddPeer adds a new peer to the database.
func (s *Store) AddPeer(ctx context.Context, pubkey string) (int64, error) {
id, err := s.db.InsertPeer(ctx, pubkey)
if err != nil {
return 0, fmt.Errorf("failed to insert peer: %w", err)
}
return id, nil
}
// GetPeer retrieves a peer by their public key.
func (s *Store) GetPeer(ctx context.Context, pubkey string) (*Peer, error) {
dbPeer, err := s.db.GetPeerByPubKey(ctx, pubkey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errUnknownPeer
}
return nil, fmt.Errorf("failed to get peer: %w", err)
}
return &Peer{
ID: dbPeer.ID,
PubKey: dbPeer.Pubkey,
}, nil
}
// int64ToSCID converts an int64 to a uint64 ShortChannelID. The BOLT spec
// encodes SCIDs as uint64, but SQL only supports signed int64. We preserve the
// bits, which means SCIDs with the high bit set will appear negative in the
// database. Direct SQL queries (e.g. ORDER BY short_channel_id) will not sort
// these correctly, but round-tripping through Go preserves the value.
func int64ToSCID(i int64) uint64 {
return uint64(i)
}
// scidToInt64 converts a uint64 ShortChannelID to an int64 for SQL storage.
func scidToInt64(u uint64) int64 {
return int64(u)
}
// AddChannel adds a new channel for a peer.
func (s *Store) AddChannel(ctx context.Context, channelPoint string,
shortChannelID uint64, peerID int64) (int64, error) {
id, err := s.db.InsertChannel(
ctx, sqlc.InsertChannelParams{
ChannelPoint: channelPoint,
ShortChannelID: scidToInt64(shortChannelID),
PeerID: peerID,
},
)
if err != nil {
return 0, fmt.Errorf("failed to insert channel: %w", err)
}
return id, nil
}
// GetChannel retrieves a channel by its channel point.
func (s *Store) GetChannel(ctx context.Context, channelPoint string) (*Channel,
error) {
dbChannel, err := s.db.GetChannelByChanPoint(ctx, channelPoint)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownChannel
}
return nil, fmt.Errorf("failed to get channel: %w", err)
}
return &Channel{
ID: dbChannel.ID,
ChannelPoint: dbChannel.ChannelPoint,
ShortChannelID: int64ToSCID(dbChannel.ShortChannelID),
PeerID: dbChannel.PeerID,
}, nil
}
// GetChannelByShortChanID retrieves a channel by its short channel ID,
// returning ErrUnknownChannel if no row matches.
func (s *Store) GetChannelByShortChanID(ctx context.Context,
shortChannelID uint64) (*Channel, error) {
dbChannel, err := s.db.GetChannelByShortChanID(
ctx, scidToInt64(shortChannelID),
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownChannel
}
return nil, err
}
return &Channel{
ID: dbChannel.ID,
ChannelPoint: dbChannel.ChannelPoint,
ShortChannelID: int64ToSCID(dbChannel.ShortChannelID),
PeerID: dbChannel.PeerID,
}, nil
}
// AddChannelEvent adds a new channel event.
func (s *Store) AddChannelEvent(ctx context.Context,
event *ChannelEvent) error {
log.Tracef("Adding channel event: %+v", event)
var localBalance sql.NullInt64
event.LocalBalance.WhenSome(
func(b btcutil.Amount) {
localBalance.Int64 = int64(b)
localBalance.Valid = true
},
)
var remoteBalance sql.NullInt64
event.RemoteBalance.WhenSome(
func(b btcutil.Amount) {
remoteBalance.Int64 = int64(b)
remoteBalance.Valid = true
},
)
timestamp := event.Timestamp.UTC()
if timestamp.IsZero() {
timestamp = s.clock.Now().UTC()
}
err := s.db.InsertChannelEvent(
ctx, sqlc.InsertChannelEventParams{
ChannelID: event.ChannelID,
EventType: int16(event.EventType),
Timestamp: timestamp,
LocalBalanceSat: localBalance,
RemoteBalanceSat: remoteBalance,
IsSync: event.IsSync,
},
)
if err != nil {
return fmt.Errorf("failed to insert channel event: %w", err)
}
return nil
}
// GetChannelEvents returns up to limit events for a channel where
// id > afterID AND startTime <= timestamp < endTime, ordered by id ASC.
// Pass afterID = 0 on the first call; for subsequent calls pass the
// previous page's last event id. The (startTime, endTime) bounds are
// independent filters and do not need to advance between pages.
func (s *Store) GetChannelEvents(ctx context.Context, channelID, afterID int64,
startTime, endTime time.Time, limit int32) ([]*ChannelEvent, error) {
dbEvents, err := s.db.GetChannelEvents(
ctx, sqlc.GetChannelEventsParams{
ChannelID: channelID,
ID: afterID,
Timestamp: startTime.UTC(),
Timestamp_2: endTime.UTC(),
Limit: limit,
},
)
if err != nil {
return nil, fmt.Errorf("failed to get channel events: %w", err)
}
events := make([]*ChannelEvent, len(dbEvents))
for i, dbEvent := range dbEvents {
events[i] = marshalChannelEvent(dbEvent)
}
return events, nil
}
// ScidToPeerMap returns the historic scid→peer index, including channels that
// have since closed. Unconfirmed channels (scid still zero) are not part of
// the contract.
func (s *Store) ScidToPeerMap(ctx context.Context) (map[uint64]string, error) {
dbChannels, err := s.db.GetChannels(ctx)
if err != nil {
return nil, err
}
scidToPeer := make(map[uint64]string, len(dbChannels))
for _, dbChannel := range dbChannels {
// The short channel ID can be zero if it's not known yet. We
// should just ignore those.
if dbChannel.ShortChannelID == 0 {
continue
}
scidToPeer[uint64(dbChannel.ShortChannelID)] = dbChannel.Pubkey
}
return scidToPeer, nil
}
// GetLatestChannelUpdateBefore returns the latest channel event before a given
// time (exclusive). If no event is found, it returns (nil, nil).
func (s *Store) GetLatestChannelUpdateBefore(ctx context.Context,
channelID int64, before time.Time) (*ChannelEvent, error) {
dbEvent, err := s.db.GetLatestChannelEventBefore(
ctx, sqlc.GetLatestChannelEventBeforeParams{
ChannelID: channelID,
Timestamp: before.UTC(),
EventType: int16(EventTypeUpdate),
},
)
if err != nil {
// If there are no events before the start time, we return (nil,
// nil).
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return marshalChannelEvent(dbEvent), nil
}
// PruneEvents enforces the size and age storage limits independently,
// returning the number of events deleted. A zero maxEvents or retention
// disables the corresponding limit, and zero for both disables pruning.
func (s *Store) PruneEvents(ctx context.Context, maxEvents uint64,
retention time.Duration) (int64, error) {
// If both options are 0, pruning is completely disabled.
if maxEvents == 0 && retention == 0 {
return 0, nil
}
var pruned int64
// Enforce the size ceiling by keeping only the newest maxEvents rows.
// An offset of (maxEvents - 1) lands on the oldest row we want to keep,
// so everything with a smaller id is deleted.
if maxEvents > 0 {
// The size limit becomes an int32 SQL OFFSET below. ValidateConfig
// already rejects an out-of-range max-events, but it is not run on
// every initialization path (e.g. when faraday runs as a
// subserver), so guard the cast here too: an overflowing value
// would wrap to a tiny offset and prune almost the entire table.
if maxEvents > math.MaxInt32 {
return pruned, fmt.Errorf("maxEvents %d exceeds maximum "+
"allowed value %d", maxEvents, math.MaxInt32)
}
bySize, err := s.db.PruneChannelEventsBySize(
ctx, int32(maxEvents-1),
)
if err != nil {
return pruned, fmt.Errorf("failed to prune channel "+
"events by size: %w", err)
}
pruned += bySize
}
// Enforce the retention window by deleting anything older than the
// cutoff.
if retention > 0 {
cutoff := s.clock.Now().UTC().Add(-retention)
byAge, err := s.db.PruneChannelEventsByAge(ctx, cutoff)
if err != nil {
return pruned, fmt.Errorf("failed to prune channel "+
"events by age: %w", err)
}
pruned += byAge
}
return pruned, nil
}
// marshalChannelEvent converts a db channel event into our internal type.
func marshalChannelEvent(dbEvent sqlc.ChannelEvent) *ChannelEvent {
var localBalance fn.Option[btcutil.Amount]
if dbEvent.LocalBalanceSat.Valid {
amt := btcutil.Amount(dbEvent.LocalBalanceSat.Int64)
localBalance = fn.Some(amt)
}
var remoteBalance fn.Option[btcutil.Amount]
if dbEvent.RemoteBalanceSat.Valid {
amt := btcutil.Amount(dbEvent.RemoteBalanceSat.Int64)
remoteBalance = fn.Some(amt)
}
return &ChannelEvent{
ID: dbEvent.ID,
ChannelID: dbEvent.ChannelID,
EventType: EventType(dbEvent.EventType),
Timestamp: dbEvent.Timestamp.UTC(),
LocalBalance: localBalance,
RemoteBalance: remoteBalance,
IsSync: dbEvent.IsSync,
}
}