Merge pull request #4 from lightninglabs/recs-uptimecomparison

Uptime Based Close Recommendations
This commit is contained in:
Carla Kirk-Cohen 2020-01-07 16:33:14 +02:00 committed by GitHub
commit c5ff04485f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 954 additions and 16 deletions

View file

@ -25,3 +25,7 @@ linters:
# Test functions tend to be longer.
- funlen
# Comments for config variables need long comments.
- lll

View file

@ -2,15 +2,19 @@ package terminator
import (
"fmt"
"time"
"github.com/jessevdk/go-flags"
"github.com/lightningnetwork/lnd/build"
)
const (
defaultRPCPort = "10009"
defaultRPCHostPort = "localhost:" + defaultRPCPort
defaultMacaroon = "admin.macaroon"
defaultNetwork = "mainnet"
defaultRPCPort = "10009"
defaultRPCHostPort = "localhost:" + defaultRPCPort
defaultMacaroon = "admin.macaroon"
defaultNetwork = "mainnet"
defaultMinimumMonitor = time.Hour * 24 * 7 * 4 // four weeks in hours
defaultDebugLevel = "info"
)
type config struct {
@ -35,8 +39,15 @@ type config struct {
// Simnet is set to true when using bitcoind's regtest.
Regtest bool `long:"regtest" description:"Use regtest"`
// MinimumMonitored is the minimum amount of time that a channel must be monitored for before we consider it for termination.
MinimumMonitored time.Duration `long:"min_monitored" description:"The minimum amount of time that a channel must be monitored for before recommending termination. Valid time units are {s, m, h}."`
// network is a string containing the network we're running on.
network string
// DebugLevel is a string defining the log level for the service either
// for all subsystems the same or individual level by subsystem.
DebugLevel string `long:"debuglevel" description:"Debug level for termaintor and its subsystems."`
}
// loadConfig starts with a skeleton default config, and reads in user provided
@ -46,9 +57,11 @@ type config struct {
func loadConfig() (*config, error) {
// Start with a default config.
config := &config{
RPCServer: defaultRPCHostPort,
network: defaultNetwork,
MacaroonFile: defaultMacaroon,
RPCServer: defaultRPCHostPort,
network: defaultNetwork,
MacaroonFile: defaultMacaroon,
MinimumMonitored: defaultMinimumMonitor,
DebugLevel: defaultDebugLevel,
}
// Parse command line options to obtain user specified values.
@ -74,5 +87,9 @@ func loadConfig() (*config, error) {
return nil, fmt.Errorf("do not specify more than one network flag")
}
if err := build.ParseAndSetDebugLevels(config.DebugLevel, logWriter); err != nil {
return nil, err
}
return config, nil
}

199
dataset/dataset.go Normal file
View file

@ -0,0 +1,199 @@
// Package dataset provides a basic dataset type which provides functionality
// for detecting inter-quartile range outliers.
package dataset
import (
"errors"
"sort"
)
const (
// weakOutlierMultiplier is the multiplier that we apply to the
// inter-quartile range to calculate weak outliers. Using this value is
// less cautious than using the strong outlier multiplier because it
// will flag values that are closer to the lower/upper quartiles as
// outliers.
weakOutlierMultiplier = 1.5
// strongOutlierMultiplier is the multiplier that we apply to the
// inter-quartile range to calculate strong outliers. Using this value is
// more cautious than using the weak outlier multiplier because it will only
// flag extreme outliers.
strongOutlierMultiplier = 3
)
var (
// errNoValues is returned when an attempt is made to calculate the median of
// a zero length array.
errNoValues = errors.New("can't calculate median for zero length " +
"array")
// ErrTooFewValues is returned when there are too few values provided to
// calculate quartiles.
ErrTooFewValues = errors.New("can't calculate quartiles for fewer than 3 " +
"elements")
)
// Dataset contains information about a set of float64 data points.
type Dataset map[string]float64
// getMedian gets the median for a set of *already sorted* values. It returns
// an error if there are no values.
func getMedian(values []float64) (float64, error) {
valuesCount := len(values)
if valuesCount == 0 {
return 0, errNoValues
}
// If there is an even number of values in the dataset, return the average
// of the values in the middle of the dataset as the median.
if valuesCount%2 == 0 {
return (values[(valuesCount-1)/2] + values[valuesCount/2]) / 2, nil
}
// If there is an odd number of values in the dataset, return the middle
// element as the median.
return values[valuesCount/2], nil
}
// New returns takes a map of labels to values and returns it as a dataset.
func New(valueMap map[string]float64) Dataset {
return valueMap
}
// rawValues returns the values for a dataset without their string label. The
// values are sorted in ascending order.
func (d Dataset) rawValues() []float64 {
values := make([]float64, 0, len(d))
for _, value := range d {
values = append(values, value)
}
// Sort the dataset in ascending order.
sort.Float64s(values)
return values
}
// quartiles returns the upper and lower quartiles of a dataset. It will fail if
// there are fewer than 3 values in the dataset, because we cannot calculate
// quartiles for fewer than 3 values.
func (d Dataset) quartiles() (float64, float64, error) {
valueCount := len(d)
if valueCount < 3 {
return 0, 0, ErrTooFewValues
}
// Get the cutoff points for calculating the lower and upper quartiles.
// The "exclusive" method of calculating quartiles is used, meaning that
// the dataset is split in half, excluding the median value in the case
// of an odd number of elements.
var cutoffLower, cutoffUpper int
if valueCount%2 == 0 {
// For an even number of elements, we split the dataset exactly in half.
cutoffLower = valueCount / 2
cutoffUpper = valueCount / 2
} else {
// For an odd number of elements, we exclude the middle element by
// returning cutoff points on either side of it.
cutoffLower = (valueCount - 1) / 2
cutoffUpper = cutoffLower + 1
}
rawValues := d.rawValues()
lowerQuartile, err := getMedian(rawValues[:cutoffLower])
if err != nil {
return 0, 0, err
}
upperQuartile, err := getMedian(rawValues[cutoffUpper:])
if err != nil {
return 0, 0, err
}
return lowerQuartile, upperQuartile, nil
}
// OutlierResult returns the results of an outlier check.
type OutlierResult struct {
// UpperOutlier is true if the value is an upper outlier in the dataset.
UpperOutlier bool
// LowerOutlier is true if the value is a lower outlier in the dataset.
LowerOutlier bool
}
// isIQROutlier returns an outlier result which indicates whether a value is an
// upper or lower outlier (or not an outlier) for the dataset.
// If a value is an upper or lower outlier, the result is recorded in the
// corresponding bool in an outlier result.
func (d Dataset) isIQROutlier(value float64, lowerQuartile,
upperQuartile float64, strong bool) *OutlierResult {
interquartileRange := upperQuartile - lowerQuartile
// quartileDistance is the distance from the upper/lower quartile a value
// must be to be considered an outlier. A larger quartile distance more
// strictly classifies outliers, because they are required to be further
// from the upper/lower quartile. Based on whether we want to find strong or
// weak outliers, the inter-quartile range (which is the base unit for this
// distance) is multiplier by a strong or weak multiplier.
quartileDistance := interquartileRange * weakOutlierMultiplier
if strong {
quartileDistance = interquartileRange * strongOutlierMultiplier
}
return &OutlierResult{
// A value is considered to be a upper outlier if it lies above the
// upper quartile by the chosen quartile distance.
UpperOutlier: value > upperQuartile+quartileDistance,
// A value is considered to be a lower outlier if it lies beneath the
// lower quartile by the chosen quartile distance for calculating
// outliers.
LowerOutlier: value < lowerQuartile-quartileDistance,
}
}
// GetOutliers returns a map of the labels in the dataset to outlier results
// which indicate whether the associated value is an upper or lower inter-
// quartile outlier.
//
// Strong is set to adjust whether we check for a strong or weak outlier. Strong
// outliers are 3 inter-quartile ranges below/above the lower/upper quartile and
// weak outliers are 1.5 inter-quartile ranges below/above the lower/upper
// quartile.
//
// Given some random set of data, with lower quartile = 5 and upper quartile
// = 6, the inter-quartile range is 1.
//
// LQ UQ
// [ 1 2 5 5 5 6 6 6 8 11 ]
//
// For strong outliers, we multiply the inter-quartile range by 3 then check
// whether a value is below the lower quartile or above the upper quartile by
// that amount to determine whether it is a lower or upper outlier.
// Strong lower outlier bound: 5 - (1 * 3) = 2
// -> 1 is a strong lower outlier
// Strong upper outlier bound: 6 + (1 * 3) = 9
// -> 11 is a strong upper outlier
//
// For weak outliers, we perform the same check, but we multiply the inter-
// quartile range by 1.5 rather than 3.
// Weak lower outlier bound: 5 - (1 * 1.5) = 3.5
// -> 1 and 2 are weak lower outliers
// Weak upper outlier bound: 6 + (1 *1.5) = 7.5
// -> 8 and 11 are weak upper outliers
func (d Dataset) GetOutliers(strong bool) (map[string]*OutlierResult, error) {
lower, upper, err := d.quartiles()
if err != nil {
return nil, err
}
outliers := make(map[string]*OutlierResult, len(d))
for label, value := range d {
outliers[label] = d.isIQROutlier(value, lower, upper, strong)
}
return outliers, nil
}

244
dataset/dataset_test.go Normal file
View file

@ -0,0 +1,244 @@
package dataset
import (
"fmt"
"testing"
)
// TestGetMedian tests median calculation for a series of inputs, including
// the error case where there are no values.
func TestGetMedian(t *testing.T) {
tests := []struct {
name string
values []float64
expectedErr error
expectedMedian float64
}{
{
name: "no values",
values: []float64{},
expectedErr: errNoValues,
},
{
name: "one value",
values: []float64{1},
expectedErr: nil,
expectedMedian: 1,
},
{
name: "two values",
values: []float64{1, 2},
expectedErr: nil,
expectedMedian: 1.5,
},
{
name: "three values",
values: []float64{1, 2, 3},
expectedErr: nil,
expectedMedian: 2,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
median, err := getMedian(test.values)
if err != test.expectedErr {
t.Fatalf("expected: %v, got: %v", test.expectedErr, err)
}
if test.expectedMedian != median {
t.Fatalf("expected: %v, got: %v", test.expectedMedian, median)
}
})
}
}
// TestQuartiles tests getting of upper and lower quartiles for a dataset. It
// tests the case where the dataset does not have enough values, and cases with
// odd and even numbers of values to test the splitting of the dataset.
func TestQuartiles(t *testing.T) {
tests := []struct {
name string
values []float64
expectedErr error
expectedLowerQuartile float64
expectedUpperQuartile float64
}{
{
name: "no elements",
values: []float64{},
expectedErr: ErrTooFewValues,
},
{
name: "three elements",
values: []float64{3, 1, 2},
expectedLowerQuartile: 1,
expectedUpperQuartile: 3,
},
{
name: "four elements",
values: []float64{1, 2, 3, 4},
expectedLowerQuartile: 1.5,
expectedUpperQuartile: 3.5,
},
{
name: "five elements",
values: []float64{1, 2, 4, 3, 5},
expectedLowerQuartile: 1.5,
expectedUpperQuartile: 4.5,
},
{
name: "eight elements",
values: []float64{1, 2, 3, 4, 5, 6, 7, 8},
expectedLowerQuartile: 2.5,
expectedUpperQuartile: 6.5,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// Create a map of dummy outpoints to values to create the dataset
// so that each test case does not need to create maps.
valueMap := make(map[string]float64)
for i, value := range test.values {
valueMap[fmt.Sprintf("%v", i)] = value
}
dataset := New(valueMap)
lower, upper, err := dataset.quartiles()
if err != test.expectedErr {
t.Fatalf("expected: %v, got: %v", test.expectedErr, err)
}
// If an error occurred, we do not need to perform any further
// checks.
if err != nil {
return
}
if test.expectedLowerQuartile != lower {
t.Fatalf("expected: %v, got: %v",
test.expectedLowerQuartile, lower)
}
if test.expectedUpperQuartile != upper {
t.Fatalf("expected: %v, got: %v",
test.expectedUpperQuartile, upper)
}
})
}
}
// TestIsOutlier tests getting of upper and lower interquartile outliers.
func TestIsOutlier(t *testing.T) {
// noOutlier is a outlier result for a value which is not an outlier.
noOutlier := &OutlierResult{}
tests := []struct {
name string
values map[string]float64
expectedError error
expectedOutliers map[string]*OutlierResult
strong bool
}{
{
name: "too few values",
expectedError: ErrTooFewValues,
values: make(map[string]float64),
strong: true,
},
{
name: "lower outlier",
values: map[string]float64{
"a": 1,
"b": 7,
"c": 7,
"d": 8,
"e": 8,
"f": 10,
},
strong: true,
expectedOutliers: map[string]*OutlierResult{
"a": {
UpperOutlier: false,
LowerOutlier: true,
},
"b": noOutlier,
"c": noOutlier,
"d": noOutlier,
"e": noOutlier,
"f": noOutlier,
},
},
{
name: "upper outlier",
values: map[string]float64{
"a": 1,
"b": 1,
"c": 2,
"d": 2,
"e": 3,
"f": 10,
},
strong: true,
expectedOutliers: map[string]*OutlierResult{
"a": noOutlier,
"b": noOutlier,
"c": noOutlier,
"d": noOutlier,
"e": noOutlier,
"f": {
UpperOutlier: true,
LowerOutlier: false,
},
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
dataset := New(test.values)
outliers, err := dataset.GetOutliers(test.strong)
if err != test.expectedError {
t.Fatalf("expected: %v, got: %v", test.expectedError, err)
}
// If the error is non-nil, there is no need for further checks.
if err != nil {
return
}
for label, outlier := range outliers {
expectedOutlier, ok := test.expectedOutliers[label]
if !ok {
t.Fatalf("outlier label: %v not expected", label)
}
if outlier.LowerOutlier != expectedOutlier.LowerOutlier {
t.Fatalf("expected lower outlier: %v, got: %v for: %v",
expectedOutlier.LowerOutlier, outlier.LowerOutlier,
label)
}
if outlier.UpperOutlier != expectedOutlier.UpperOutlier {
t.Fatalf("expected upper outlier: %v, got: %v for: %v",
expectedOutlier.UpperOutlier, outlier.UpperOutlier,
label)
}
}
})
}
}

34
log.go
View file

@ -2,20 +2,26 @@ package terminator
import (
"github.com/btcsuite/btclog"
"github.com/lightninglabs/terminator/recommend"
"github.com/lightningnetwork/lnd/build"
)
// Subsystem defines the logging code for this subsystem.
const Subsystem = "TERM"
// log is a logger that is initialized with no output filters. This
// means the package will not perform any logging by default until the
// caller requests it.
var log btclog.Logger
var (
logWriter = build.NewRotatingLogWriter()
// log is a logger that is initialized with no output filters. This
// means the package will not perform any logging by default until the
// caller requests it.
log = build.NewSubLogger(Subsystem, logWriter.GenSubLogger)
)
// The default amount of logging is none.
func init() {
UseLogger(build.NewSubLogger(Subsystem, nil))
setSubLogger(Subsystem, log, nil)
addSubLogger(recommend.Subsystem, recommend.UseLogger)
}
// UseLogger uses a specified Logger to output package logging info.
@ -24,3 +30,21 @@ func init() {
func UseLogger(logger btclog.Logger) {
log = logger
}
// addSubLogger is a helper method to conveniently create and register the
// logger of a sub system.
func addSubLogger(subsystem string, useLogger func(btclog.Logger)) {
logger := build.NewSubLogger(subsystem, logWriter.GenSubLogger)
setSubLogger(subsystem, logger, useLogger)
}
// setSubLogger is a helper method to conveniently register the logger of a sub
// system.
func setSubLogger(subsystem string, logger btclog.Logger,
useLogger func(btclog.Logger)) {
logWriter.RegisterSubLogger(subsystem, logger)
if useLogger != nil {
useLogger(logger)
}
}

26
recommend/log.go Normal file
View file

@ -0,0 +1,26 @@
package recommend
import (
"github.com/btcsuite/btclog"
"github.com/lightningnetwork/lnd/build"
)
// Subsystem defines the logging code for this subsystem.
const Subsystem = "RECM"
// log is a logger that is initialized with no output filters. This
// means the package will not perform any logging by default until the
// caller requests it.
var log btclog.Logger
// The default amount of logging is none.
func init() {
UseLogger(build.NewSubLogger(Subsystem, nil))
}
// UseLogger uses a specified Logger to output package logging info.
// This should be used in preference to SetLogWriter if the caller is also
// using btclog.
func UseLogger(logger btclog.Logger) {
log = logger
}

164
recommend/recommend.go Normal file
View file

@ -0,0 +1,164 @@
// Package recommend provides recommendations for closing channels with the
// constraints provided in its close recommendation config. Only open public
// channels that have been monitored for the configurable minimum monitored
// time will be considered for closing.
//
// Channels will be assessed based on the following data points:
// - Uptime percentage
//
// Channels that are outliers within the set of channels that are eligible for
// close recommendation will be recommended for closure.
package recommend
import (
"errors"
"time"
"github.com/lightninglabs/terminator/dataset"
"github.com/lightningnetwork/lnd/lnrpc"
)
// errZeroMinMonitored is returned when the minimum ages provided by the config
// is zero.
var errZeroMinMonitored = errors.New("must provide a non-zero minimum " +
"monitor time for channel exclusion")
// CloseRecommendationConfig provides the functions and parameters required to
// provide close recommendations.
type CloseRecommendationConfig struct {
// OpenChannels is a function which returns all of our currently open,
// public channels.
OpenChannels func() ([]*lnrpc.Channel, error)
// StrongOutlier is set to true if only extreme outliers should be
// recommended for close. A strong outlier is one which is 3 inter-
// quartile ranges below the lower quartile (or above the upper quartile)
// amd a weak outlier is only 1.5 inter-quartile ranges away. Choosing
// to recommend strong outliers is a more cautious approach, because the
// recommendations will be more lenient, only recommending extreme outliers
// for closure.
StrongOutlier bool
// MinimumMonitored is the minimum amount of time that a channel must have
// been monitored for before it is considered for closing.
MinimumMonitored time.Duration
}
// Report contains a set of close recommendations and information about the
// number of channels considered for close.
type Report struct {
// TotalChannels is the number of channels that we have.
TotalChannels int
// ConsideredChannels is the number of channels that have been monitored
// for long enough to be considered for close.
ConsideredChannels int
// Recommendations is a map of chanel outpoints to a bool which indicates
// whether we should close the channel.
Recommendations map[string]bool
}
// CloseRecommendations returns a report which contains information about the
// channels that were considered and a list of close recommendations. Channels
// are considered for close if their uptime percentage is a lower outlier in
// uptime percentage dataset.
func CloseRecommendations(cfg *CloseRecommendationConfig) (*Report, error) {
// Check that the minimum wait time is non-zero.
if cfg.MinimumMonitored == 0 {
return nil, errZeroMinMonitored
}
// Get the set of currently open channels.
channels, err := cfg.OpenChannels()
if err != nil {
return nil, err
}
// Filter out channels that are below the minimum required age.
filtered := filterChannels(channels, cfg.MinimumMonitored)
// Produce a dataset containing uptime percentage for channels that have
// been monitored for longer than the minimum time.
uptime := getUptimeDataset(filtered)
recs, err := getCloseRecs(uptime, cfg.StrongOutlier)
if err != nil {
return nil, err
}
return &Report{
TotalChannels: len(channels),
ConsideredChannels: len(uptime),
Recommendations: recs,
}, nil
}
// getCloseRecs generates map of channel outpoint strings to booleans indicating
// whether we recommend closing a channel.
func getCloseRecs(uptime dataset.Dataset,
strongOutlier bool) (map[string]bool, error) {
outliers, err := uptime.GetOutliers(strongOutlier)
if err != nil {
return nil, err
}
recommendations := make(map[string]bool)
for chanpoint, outlier := range outliers {
// If the channel is a lower outlier, recommend it for closure.
if outlier.LowerOutlier {
recommendations[chanpoint] = true
}
}
return recommendations, nil
}
// filterChannels filters out channels that are beneath the minimum age and
// produces a map of channel outpoint strings to rpc channels which contains the
// channels that are eligible for close recommendation.
func filterChannels(openChannels []*lnrpc.Channel,
minimumAge time.Duration) map[string]*lnrpc.Channel {
// Create a map which will hold channel point labels to uptime percentage.
channels := make(map[string]*lnrpc.Channel)
for _, channel := range openChannels {
if channel.Lifetime < int64(minimumAge.Seconds()) {
log.Tracef("Channel: %v has not been monitored for long enough,"+
" excluding it from consideration", channel.ChannelPoint)
continue
}
channels[channel.ChannelPoint] = channel
}
log.Debugf("considering: % channels for close out of %v",
len(channels), len(openChannels))
return channels
}
// getUptimeDataset takes a set of channels that are eligible for close and
// produces an uptime dataset.
func getUptimeDataset(
eligibleChannels map[string]*lnrpc.Channel) dataset.Dataset {
// Create a map which will hold channel point string label to uptime percentage.
var channels = make(map[string]float64)
for outpoint, channel := range eligibleChannels {
// Calculate the uptime percentage for the channel and add it to the
// channel -> uptime map.
uptimePercentage := float64(channel.Uptime) / float64(channel.Lifetime)
channels[outpoint] = uptimePercentage
log.Tracef("channel: %v has uptime percentage: %v", outpoint,
uptimePercentage)
}
// Create a dataset for the uptime values we have collected.
return dataset.New(channels)
}

228
recommend/recommend_test.go Normal file
View file

@ -0,0 +1,228 @@
package recommend
import (
"errors"
"testing"
"time"
"github.com/lightninglabs/terminator/dataset"
"github.com/lightningnetwork/lnd/lnrpc"
)
// TestCloseRecommendations tests CloseRecommendations for error cases where
// the function provided to list channels fails or the config provided is
// invalid. It also has cases for calls which return not enough channels, and
// the minimum acceptable number of channels. It does not test the report
// provided, because that will be covered by further tests.
func TestCloseRecommendations(t *testing.T) {
var openChanErr = errors.New("intentional test err")
tests := []struct {
name string
OpenChannels func() ([]*lnrpc.Channel, error)
MinMonitored time.Duration
expectedErr error
}{
{
name: "no channels",
OpenChannels: func() ([]*lnrpc.Channel, error) {
return nil, nil
},
MinMonitored: time.Hour,
expectedErr: dataset.ErrTooFewValues,
},
{
name: "open channels fails",
OpenChannels: func() ([]*lnrpc.Channel, error) {
return nil, openChanErr
},
MinMonitored: time.Hour,
expectedErr: openChanErr,
},
{
name: "zero min monitored",
OpenChannels: func() ([]*lnrpc.Channel, error) {
return nil, nil
},
MinMonitored: 0,
expectedErr: errZeroMinMonitored,
},
{
name: "enough channels",
OpenChannels: func() ([]*lnrpc.Channel, error) {
return []*lnrpc.Channel{
{
ChannelPoint: "a:1",
Lifetime: int64(time.Hour.Seconds()),
},
{
ChannelPoint: "b:2",
Lifetime: int64(time.Hour.Seconds()),
},
{
ChannelPoint: "c:3",
Lifetime: int64(time.Hour.Seconds()),
},
}, nil
},
MinMonitored: time.Hour,
expectedErr: nil,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
_, err := CloseRecommendations(&CloseRecommendationConfig{
OpenChannels: test.OpenChannels,
StrongOutlier: true,
MinimumMonitored: test.MinMonitored,
})
if err != test.expectedErr {
t.Fatalf("expected: %v, got: %v", test.expectedErr, err)
}
})
}
}
// TestGetCloseRecs tests the generating of close recommendations for a set of
// channels.
func TestGetCloseRecs(t *testing.T) {
tests := []struct {
name string
channelUptimes map[string]float64
expectedRecs map[string]bool
strongOutlier bool
}{
{
name: "similar values, weak outlier no recommendations",
channelUptimes: map[string]float64{
"a:0": 0.7,
"a:1": 0.6,
"a:20": 0.5,
},
strongOutlier: false,
expectedRecs: map[string]bool{},
},
{
name: "similar values, strong outlier no recommendations",
channelUptimes: map[string]float64{
"a:0": 0.7,
"a:1": 0.6,
"a:2": 0.5,
},
strongOutlier: true,
expectedRecs: map[string]bool{},
},
{
name: "lower outlier recommended for close",
channelUptimes: map[string]float64{
"a:0": 0.6,
"a:1": 0.6,
"a:2": 0.5,
"a:3": 0.5,
"a:4": 0.5,
"a:5": 0.1,
},
strongOutlier: true,
expectedRecs: map[string]bool{
"a:5": true,
},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
uptimeData := dataset.New(test.channelUptimes)
recs, err := getCloseRecs(uptimeData, test.strongOutlier)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Run through our expected set of recommendations and check that
// they match the set returned in the report.
for channel, expectClose := range test.expectedRecs {
recClose := recs[channel]
if recClose != expectClose {
t.Fatalf("expected close rec: %v for channel: %v,"+
" got: %v", expectClose, channel, recClose)
}
}
})
}
}
// TestFilterChannels tests filtering of channels based on their lifetime.
func TestFilterChannels(t *testing.T) {
openChannels := []*lnrpc.Channel{
{
ChannelPoint: "a:0",
Lifetime: 10,
Uptime: 1,
},
{
ChannelPoint: "a:1",
Lifetime: 100,
Uptime: 1,
},
{
ChannelPoint: "a:2",
Lifetime: 100,
Uptime: 1,
},
{
ChannelPoint: "a:3",
Lifetime: 100,
Uptime: 1,
},
}
tests := []struct {
name string
openChannels []*lnrpc.Channel
minAge time.Duration
expectedChanPoints []string
}{
{
name: "one channel not monitored for long enough",
openChannels: openChannels,
minAge: time.Second * 15,
expectedChanPoints: []string{"a:1", "a:2", "a:3"},
},
{
name: "all channels included",
openChannels: openChannels,
minAge: time.Second * 5,
expectedChanPoints: []string{"a:0", "a:1", "a:2", "a:3"},
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
filtered := filterChannels(test.openChannels, test.minAge)
if len(test.expectedChanPoints) != len(filtered) {
t.Fatalf("expected: %v channels, got: %v",
len(test.expectedChanPoints), len(filtered))
}
for _, expected := range test.expectedChanPoints {
if _, ok := filtered[expected]; !ok {
t.Fatalf("expected channel: %v to be present", expected)
}
}
})
}
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"github.com/lightninglabs/loop/lndclient"
"github.com/lightninglabs/terminator/recommend"
"github.com/lightningnetwork/lnd/lnrpc"
)
@ -32,13 +33,44 @@ func Main() error {
return fmt.Errorf("cannot connect to lightning client: %v", err)
}
channels, err := client.ListChannels(ctx, &lnrpc.ListChannelsRequest{})
// Get channel close recommendations for the current set of open public
// channels.
report, err := recommend.CloseRecommendations(
&recommend.CloseRecommendationConfig{
// OpenChannels provides all of the open, public channels for the
// node.
OpenChannels: func() (channels []*lnrpc.Channel, e error) {
resp, err := client.ListChannels(ctx,
&lnrpc.ListChannelsRequest{
PublicOnly: true,
})
if err != nil {
return nil, err
}
return resp.Channels, nil
},
// For the first iteration of the terminator, do not allow users
// to configure recommendations to penalize weak outliers.
StrongOutlier: true,
// Set the minimum monitor time to the value provided in our config.
MinimumMonitored: config.MinimumMonitored,
})
if err != nil {
return fmt.Errorf("error calling list channels: %v", err)
return fmt.Errorf("could not get close recommendations: %v", err)
}
log.Infof("Found %v channels, that's all for now. I will be back.",
len(channels.Channels))
log.Infof("Considering: %v channels for closure from a "+
"total of: %v. Produced %v recommendations.", report.ConsideredChannels,
report.TotalChannels, len(report.Recommendations))
for channel, rec := range report.Recommendations {
log.Infof("%v: %v", channel, rec)
}
log.Info("That's all for now. I will be back.")
return nil
}