diff --git a/.golangci.yml b/.golangci.yml index bd5650f..ba1401a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -25,3 +25,7 @@ linters: # Test functions tend to be longer. - funlen + + # Comments for config variables need long comments. + - lll + diff --git a/config.go b/config.go index 882e73b..0f57d26 100644 --- a/config.go +++ b/config.go @@ -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 } diff --git a/dataset/dataset.go b/dataset/dataset.go new file mode 100644 index 0000000..a3f6c9e --- /dev/null +++ b/dataset/dataset.go @@ -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 +} diff --git a/dataset/dataset_test.go b/dataset/dataset_test.go new file mode 100644 index 0000000..abb6743 --- /dev/null +++ b/dataset/dataset_test.go @@ -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) + } + } + }) + } +} diff --git a/log.go b/log.go index 530c66f..d054944 100644 --- a/log.go +++ b/log.go @@ -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) + } +} diff --git a/recommend/log.go b/recommend/log.go new file mode 100644 index 0000000..823bb44 --- /dev/null +++ b/recommend/log.go @@ -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 +} diff --git a/recommend/recommend.go b/recommend/recommend.go new file mode 100644 index 0000000..340c104 --- /dev/null +++ b/recommend/recommend.go @@ -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) +} diff --git a/recommend/recommend_test.go b/recommend/recommend_test.go new file mode 100644 index 0000000..b2c54a3 --- /dev/null +++ b/recommend/recommend_test.go @@ -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) + } + } + }) + } +} diff --git a/terminator.go b/terminator.go index dcecb51..4f4123c 100644 --- a/terminator.go +++ b/terminator.go @@ -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 }