Merge pull request #8549 from matheusdtech/mc-store-perf-improv

Misson Control Store: Improve performance
This commit is contained in:
Elle authored and GitHub committed 2024-06-19 09:15:07 -07:00
commit 2477bd756f
3 files changed
+361 -110

No files matched your search

@@ -74,6 +74,9 @@
## Breaking Changes ## Breaking Changes
## Performance Improvements ## Performance Improvements
* Mission Control Store [improved performance during DB
flushing](https://github.com/lightningnetwork/lnd/pull/8549) stage.
# Technical and Architectural Updates # Technical and Architectural Updates
## BOLT Spec Updates ## BOLT Spec Updates
## Testing ## Testing
@@ -85,4 +88,5 @@
* Andras Banki-Horvath * Andras Banki-Horvath
* Bufo * Bufo
* Matheus Degiovani
* Slyghtning * Slyghtning
+147 -45
View File
@@ -38,12 +38,15 @@ const (
// Also changes to mission control parameters can be applied to historical data. // Also changes to mission control parameters can be applied to historical data.
// Finally, it enables importing raw data from an external source. // Finally, it enables importing raw data from an external source.
type missionControlStore struct { type missionControlStore struct {
done chan struct{} done chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
db kvdb.Backend db kvdb.Backend
queueMx sync.Mutex
// queueCond is signalled when items are put into the queue.
queueCond *sync.Cond
// queue stores all pending payment results not yet added to the store. // queue stores all pending payment results not yet added to the store.
// Access is protected by the queueCond.L mutex.
queue *list.List queue *list.List
// keys holds the stored MC store item keys in the order of storage. // keys holds the stored MC store item keys in the order of storage.
@@ -96,9 +99,12 @@ func newMissionControlStore(db kvdb.Backend, maxRecords int,
return nil, err return nil, err
} }
log.Infof("Loaded %d mission control entries", len(keysMap))
return &missionControlStore{ return &missionControlStore{
done: make(chan struct{}), done: make(chan struct{}),
db: db, db: db,
queueCond: sync.NewCond(&sync.Mutex{}),
queue: list.New(), queue: list.New(),
keys: keys, keys: keys,
keysMap: keysMap, keysMap: keysMap,
@@ -109,8 +115,8 @@ func newMissionControlStore(db kvdb.Backend, maxRecords int,
// clear removes all results from the db. // clear removes all results from the db.
func (b *missionControlStore) clear() error { func (b *missionControlStore) clear() error {
b.queueMx.Lock() b.queueCond.L.Lock()
defer b.queueMx.Unlock() defer b.queueCond.L.Unlock()
err := kvdb.Update(b.db, func(tx kvdb.RwTx) error { err := kvdb.Update(b.db, func(tx kvdb.RwTx) error {
if err := tx.DeleteTopLevelBucket(resultsKey); err != nil { if err := tx.DeleteTopLevelBucket(resultsKey); err != nil {
@@ -265,14 +271,19 @@ func deserializeResult(k, v []byte) (*paymentResult, error) {
// AddResult adds a new result to the db. // AddResult adds a new result to the db.
func (b *missionControlStore) AddResult(rp *paymentResult) { func (b *missionControlStore) AddResult(rp *paymentResult) {
b.queueMx.Lock() b.queueCond.L.Lock()
defer b.queueMx.Unlock()
b.queue.PushBack(rp) b.queue.PushBack(rp)
b.queueCond.L.Unlock()
b.queueCond.Signal()
} }
// stop stops the store ticker goroutine. // stop stops the store ticker goroutine.
func (b *missionControlStore) stop() { func (b *missionControlStore) stop() {
close(b.done) close(b.done)
b.queueCond.Signal()
b.wg.Wait() b.wg.Wait()
} }
@@ -281,19 +292,51 @@ func (b *missionControlStore) run() {
b.wg.Add(1) b.wg.Add(1)
go func() { go func() {
ticker := time.NewTicker(b.flushInterval)
defer ticker.Stop()
defer b.wg.Done() defer b.wg.Done()
timer := time.NewTimer(b.flushInterval)
// Immediately stop the timer. It will be started once new
// items are added to the store. As the doc for time.Timer
// states, every call to Stop() done on a timer that is not
// known to have been fired needs to be checked and the timer's
// channel needs to be drained appropriately. This could happen
// if the flushInterval is very small (e.g. 1 nanosecond).
if !timer.Stop() {
<-timer.C
}
for { for {
// Wait for the queue to not be empty.
b.queueCond.L.Lock()
for b.queue.Front() == nil {
b.queueCond.Wait()
select {
case <-b.done:
b.queueCond.L.Unlock()
return
default:
}
}
b.queueCond.L.Unlock()
// Restart the timer.
timer.Reset(b.flushInterval)
select { select {
case <-ticker.C: case <-timer.C:
if err := b.storeResults(); err != nil { if err := b.storeResults(); err != nil {
log.Errorf("Failed to update mission "+ log.Errorf("Failed to update mission "+
"control store: %v", err) "control store: %v", err)
} }
case <-b.done: case <-b.done:
// Release the timer's resources.
if !timer.Stop() {
<-timer.C
}
return return
} }
} }
@@ -302,77 +345,136 @@ func (b *missionControlStore) run() {
// storeResults stores all accumulated results. // storeResults stores all accumulated results.
func (b *missionControlStore) storeResults() error { func (b *missionControlStore) storeResults() error {
b.queueMx.Lock() // We copy a reference to the queue and clear the original queue to be
// able to release the lock.
b.queueCond.L.Lock()
l := b.queue l := b.queue
if l.Len() == 0 {
b.queueCond.L.Unlock()
return nil
}
b.queue = list.New() b.queue = list.New()
b.queueMx.Unlock() b.queueCond.L.Unlock()
var ( var (
keys *list.List newKeys map[string]struct{}
keysMap map[string]struct{} delKeys []string
storeCount int
pruneCount int
) )
// Create a deduped list of new entries.
newKeys = make(map[string]struct{}, l.Len())
for e := l.Front(); e != nil; e = e.Next() {
pr, ok := e.Value.(*paymentResult)
if !ok {
return fmt.Errorf("wrong type %T (not *paymentResult)",
e.Value)
}
key := string(getResultKey(pr))
if _, ok := b.keysMap[key]; ok {
l.Remove(e)
continue
}
if _, ok := newKeys[key]; ok {
l.Remove(e)
continue
}
newKeys[key] = struct{}{}
}
// Create a list of entries to delete.
toDelete := b.keys.Len() + len(newKeys) - b.maxRecords
if b.maxRecords > 0 && toDelete > 0 {
delKeys = make([]string, 0, toDelete)
// Delete as many as needed from old keys.
for e := b.keys.Front(); len(delKeys) < toDelete && e != nil; {
key, ok := e.Value.(string)
if !ok {
return fmt.Errorf("wrong type %T (not string)",
e.Value)
}
delKeys = append(delKeys, key)
e = e.Next()
}
// If more deletions are needed, simply do not add from the
// list of new keys.
for e := l.Front(); len(delKeys) < toDelete && e != nil; {
toDelete--
pr, ok := e.Value.(*paymentResult)
if !ok {
return fmt.Errorf("wrong type %T (not "+
"*paymentResult )", e.Value)
}
key := string(getResultKey(pr))
delete(newKeys, key)
l.Remove(e)
e = l.Front()
}
}
err := kvdb.Update(b.db, func(tx kvdb.RwTx) error { err := kvdb.Update(b.db, func(tx kvdb.RwTx) error {
bucket := tx.ReadWriteBucket(resultsKey) bucket := tx.ReadWriteBucket(resultsKey)
for e := l.Front(); e != nil; e = e.Next() { for e := l.Front(); e != nil; e = e.Next() {
pr := e.Value.(*paymentResult) pr, ok := e.Value.(*paymentResult)
if !ok {
return fmt.Errorf("wrong type %T (not "+
"*paymentResult)", e.Value)
}
// Serialize result into key and value byte slices. // Serialize result into key and value byte slices.
k, v, err := serializeResult(pr) k, v, err := serializeResult(pr)
if err != nil { if err != nil {
return err return err
} }
// The store is assumed to be idempotent. It could be
// that the same result is added twice and in that case
// we don't need to put the value again.
if _, ok := keysMap[string(k)]; ok {
continue
}
// Put into results bucket. // Put into results bucket.
if err := bucket.Put(k, v); err != nil { if err := bucket.Put(k, v); err != nil {
return err return err
} }
keys.PushBack(string(k)) storeCount++
keysMap[string(k)] = struct{}{}
} }
// Prune oldest entries. // Prune oldest entries.
for { for _, key := range delKeys {
if b.maxRecords == 0 || keys.Len() <= b.maxRecords {
break
}
front := keys.Front()
key := front.Value.(string)
if err := bucket.Delete([]byte(key)); err != nil { if err := bucket.Delete([]byte(key)); err != nil {
return err return err
} }
pruneCount++
keys.Remove(front)
delete(keysMap, key)
} }
return nil return nil
}, func() { }, func() {
keys = list.New() storeCount, pruneCount = 0, 0
keys.PushBackList(b.keys)
keysMap = make(map[string]struct{})
for k := range b.keysMap {
keysMap[k] = struct{}{}
}
}) })
if err != nil { if err != nil {
return err return err
} }
b.keys = keys log.Debugf("Stored mission control results: %d added, %d deleted",
b.keysMap = keysMap storeCount, pruneCount)
// DB Update was successful, update the in-memory cache.
for _, key := range delKeys {
delete(b.keysMap, key)
b.keys.Remove(b.keys.Front())
}
for e := l.Front(); e != nil; e = e.Next() {
pr, ok := e.Value.(*paymentResult)
if !ok {
return fmt.Errorf("wrong type %T (not *paymentResult)",
e.Value)
}
key := string(getResultKey(pr))
b.keys.PushBack(key)
}
return nil return nil
} }
+210 -65
View File
@@ -1,13 +1,14 @@
package routing package routing
import ( import (
"fmt"
"os" "os"
"reflect"
"testing" "testing"
"time" "time"
"github.com/davecgh/go-spew/spew" "github.com/btcsuite/btcwallet/walletdb"
"github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/kvdb"
"github.com/lightningnetwork/lnd/lntest/wait"
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -15,18 +16,33 @@ import (
const testMaxRecords = 2 const testMaxRecords = 2
// TestMissionControlStore tests the recording of payment failure events var (
// in mission control. It tests encoding and decoding of differing lnwire // mcStoreTestRoute is a test route for the mission control store tests.
// failures (FailIncorrectDetails and FailMppTimeout), pruning of results mcStoreTestRoute = route.Route{
// and idempotent writes. SourcePubKey: route.Vertex{1},
func TestMissionControlStore(t *testing.T) { Hops: []*route.Hop{
{
PubKeyBytes: route.Vertex{2},
LegacyPayload: true,
},
},
}
)
// mcStoreTestHarness is the harness for a MissonControlStore test.
type mcStoreTestHarness struct {
db walletdb.DB
store *missionControlStore
}
// newMCStoreTestHarness initializes a test mission control store.
func newMCStoreTestHarness(t testing.TB, maxRecords int,
flushInterval time.Duration) mcStoreTestHarness {
// Set time zone explicitly to keep test deterministic. // Set time zone explicitly to keep test deterministic.
time.Local = time.UTC time.Local = time.UTC
file, err := os.CreateTemp("", "*.db") file, err := os.CreateTemp("", "*.db")
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
dbPath := file.Name() dbPath := file.Name()
t.Cleanup(func() { t.Cleanup(func() {
@@ -37,40 +53,33 @@ func TestMissionControlStore(t *testing.T) {
db, err := kvdb.Create( db, err := kvdb.Create(
kvdb.BoltBackendName, dbPath, true, kvdb.DefaultDBTimeout, kvdb.BoltBackendName, dbPath, true, kvdb.DefaultDBTimeout,
) )
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
t.Cleanup(func() { t.Cleanup(func() {
require.NoError(t, db.Close()) require.NoError(t, db.Close())
}) })
store, err := newMissionControlStore(db, testMaxRecords, time.Second) store, err := newMissionControlStore(db, maxRecords, flushInterval)
if err != nil { require.NoError(t, err)
t.Fatal(err)
} return mcStoreTestHarness{db: db, store: store}
}
// TestMissionControlStore tests the recording of payment failure events
// in mission control. It tests encoding and decoding of differing lnwire
// failures (FailIncorrectDetails and FailMppTimeout), pruning of results
// and idempotent writes.
func TestMissionControlStore(t *testing.T) {
h := newMCStoreTestHarness(t, testMaxRecords, time.Second)
db, store := h.db, h.store
results, err := store.fetchAll() results, err := store.fetchAll()
if err != nil { require.NoError(t, err)
t.Fatal(err) require.Len(t, results, 0)
}
if len(results) != 0 {
t.Fatal("expected no results")
}
testRoute := route.Route{
SourcePubKey: route.Vertex{1},
Hops: []*route.Hop{
{
PubKeyBytes: route.Vertex{2},
LegacyPayload: true,
},
},
}
failureSourceIdx := 1 failureSourceIdx := 1
result1 := paymentResult{ result1 := paymentResult{
route: &testRoute, route: &mcStoreTestRoute,
failure: lnwire.NewFailIncorrectDetails(100, 1000), failure: lnwire.NewFailIncorrectDetails(100, 1000),
failureSourceIdx: &failureSourceIdx, failureSourceIdx: &failureSourceIdx,
id: 99, id: 99,
@@ -94,30 +103,16 @@ func TestMissionControlStore(t *testing.T) {
require.NoError(t, store.storeResults()) require.NoError(t, store.storeResults())
results, err = store.fetchAll() results, err = store.fetchAll()
if err != nil { require.NoError(t, err)
t.Fatal(err) require.Len(t, results, 2)
}
require.Equal(t, 2, len(results))
if len(results) != 2 {
t.Fatal("expected two results")
}
// Check that results are stored in chronological order. // Check that results are stored in chronological order.
if !reflect.DeepEqual(&result1, results[0]) { require.Equal(t, &result1, results[0])
t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result1), require.Equal(t, &result2, results[1])
spew.Sdump(results[0]))
}
if !reflect.DeepEqual(&result2, results[1]) {
t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result2),
spew.Sdump(results[1]))
}
// Recreate store to test pruning. // Recreate store to test pruning.
store, err = newMissionControlStore(db, testMaxRecords, time.Second) store, err = newMissionControlStore(db, testMaxRecords, time.Second)
if err != nil { require.NoError(t, err)
t.Fatal(err)
}
// Add a newer result which failed due to mpp timeout. // Add a newer result which failed due to mpp timeout.
result3 := result1 result3 := result1
@@ -131,20 +126,170 @@ func TestMissionControlStore(t *testing.T) {
// Check that results are pruned. // Check that results are pruned.
results, err = store.fetchAll() results, err = store.fetchAll()
if err != nil { require.NoError(t, err)
t.Fatal(err) require.Len(t, results, 2)
}
require.Equal(t, 2, len(results)) require.Equal(t, &result2, results[0])
if len(results) != 2 { require.Equal(t, &result3, results[1])
t.Fatal("expected two results") }
// TestMissionControlStoreFlushing asserts the periodic flushing of the store
// works correctly.
func TestMissionControlStoreFlushing(t *testing.T) {
const flushInterval = 500 * time.Millisecond
h := newMCStoreTestHarness(t, testMaxRecords, flushInterval)
db, store := h.db, h.store
var (
failureSourceIdx = 1
failureDetails = lnwire.NewFailIncorrectDetails(100, 1000)
lastID uint64
)
nextResult := func() *paymentResult {
lastID += 1
return &paymentResult{
route: &mcStoreTestRoute,
failure: failureDetails,
failureSourceIdx: &failureSourceIdx,
id: lastID,
timeReply: testTime,
timeFwd: testTime.Add(-time.Minute),
}
} }
if !reflect.DeepEqual(&result2, results[0]) { // Helper to assert the number of results is correct.
t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result2), assertResults := func(wantCount int) {
spew.Sdump(results[0])) t.Helper()
err := wait.NoError(func() error {
results, err := store.fetchAll()
if err != nil {
return err
}
if wantCount != len(results) {
return fmt.Errorf("wrong nb of results: got "+
"%d, want %d", len(results), wantCount)
}
if len(results) == 0 {
return nil
}
gotLastID := results[len(results)-1].id
if len(results) > 0 && gotLastID != lastID {
return fmt.Errorf("wrong id for last item: "+
"got %d, want %d", gotLastID, lastID)
}
return nil
}, flushInterval*5)
require.NoError(t, err)
} }
if !reflect.DeepEqual(&result3, results[1]) {
t.Fatalf("the results differ: %v vs %v", spew.Sdump(&result3), // Run the store.
spew.Sdump(results[1])) store.run()
time.Sleep(flushInterval)
// Wait for the flush interval. There should be no records.
assertResults(0)
// Store a result and check immediately. There still shouldn't be
// any results stored (flush interval has not elapsed).
store.AddResult(nextResult())
assertResults(0)
// Assert that eventually the result is stored after being flushed.
assertResults(1)
// Store enough results that fill the max number of results.
for i := 0; i < testMaxRecords; i++ {
store.AddResult(nextResult())
}
assertResults(testMaxRecords)
// Finally, stop the store to recreate it.
store.stop()
// Recreate store.
store, err := newMissionControlStore(db, testMaxRecords, flushInterval)
require.NoError(t, err)
store.run()
defer store.stop()
time.Sleep(flushInterval)
assertResults(testMaxRecords)
// Fill the store with results again.
for i := 0; i < testMaxRecords; i++ {
store.AddResult(nextResult())
}
assertResults(testMaxRecords)
}
// BenchmarkMissionControlStoreFlushing benchmarks the periodic storage of data
// from the mission control store when additional results are added between
// runs.
func BenchmarkMissionControlStoreFlushing(b *testing.B) {
var (
failureSourceIdx = 1
failureDetails = lnwire.NewFailIncorrectDetails(100, 1000)
testTimeFwd = testTime.Add(-time.Minute)
tests = []int{0, 1, 10, 100, 250, 500}
)
const testMaxRecords = 1000
for _, tc := range tests {
tc := tc
name := fmt.Sprintf("%v additional results", tc)
b.Run(name, func(b *testing.B) {
h := newMCStoreTestHarness(
b, testMaxRecords, time.Second,
)
store := h.store
// Fill the store.
var lastID uint64
for i := 0; i < testMaxRecords; i++ {
lastID++
result := &paymentResult{
route: &mcStoreTestRoute,
failure: failureDetails,
failureSourceIdx: &failureSourceIdx,
id: lastID,
timeReply: testTime,
timeFwd: testTimeFwd,
}
store.AddResult(result)
}
// Do the first flush.
err := store.storeResults()
require.NoError(b, err)
// Create the additional results.
results := make([]*paymentResult, tc)
for i := 0; i < len(results); i++ {
results[i] = &paymentResult{
route: &mcStoreTestRoute,
failure: failureDetails,
failureSourceIdx: &failureSourceIdx,
timeReply: testTime,
timeFwd: testTimeFwd,
}
}
// Run the actual benchmark.
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for j := 0; j < len(results); j++ {
lastID++
results[j].id = lastID
store.AddResult(results[j])
}
err := store.storeResults()
require.NoError(b, err)
}
})
} }
} }