mirror of
https://github.com/lightninglabs/faraday.git
synced 2026-08-20 13:28:33 +02:00
Merge pull request #4 from lightninglabs/recs-uptimecomparison
Uptime Based Close Recommendations
This commit is contained in:
commit
c5ff04485f
9 changed files with 954 additions and 16 deletions
|
|
@ -25,3 +25,7 @@ linters:
|
|||
|
||||
# Test functions tend to be longer.
|
||||
- funlen
|
||||
|
||||
# Comments for config variables need long comments.
|
||||
- lll
|
||||
|
||||
|
|
|
|||
31
config.go
31
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
|
||||
}
|
||||
|
|
|
|||
199
dataset/dataset.go
Normal file
199
dataset/dataset.go
Normal 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
244
dataset/dataset_test.go
Normal 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
34
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
recommend/log.go
Normal file
26
recommend/log.go
Normal 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
164
recommend/recommend.go
Normal 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
228
recommend/recommend_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue