routing: introduce stateStep to manage payment lifecycle

This commit adds a new struct, `stateStep`, to decide the workflow
inside `resumePayment`.

It also refactors `collectResultAsync` introducing a new channel
`resultCollected`. This channel is used to signal the payment
lifecycle that an HTLC attempt result is ready to be processed.
This commit is contained in:
yyforyongyu committed 2023-11-13 16:09:11 +08:00
1 parent e8c0226e1c
commit 3c5c37b693
3 files changed
+343 -177

No files matched your search

+155 -138
View File
@@ -3,7 +3,6 @@ package routing
import (
"errors"
"fmt"
"sync"
"time"
"github.com/btcsuite/btcd/btcec/v2"
@@ -18,9 +17,6 @@ import (
"github.com/lightningnetwork/lnd/routing/shards"
)
// errShardHandlerExiting is returned from the shardHandler when it exits.
var errShardHandlerExiting = errors.New("shard handler exiting")
// paymentLifecycle holds all information about the current state of a payment
// needed to resume if from any point.
type paymentLifecycle struct {
@@ -32,18 +28,15 @@ type paymentLifecycle struct {
timeoutChan <-chan time.Time
currentHeight int32
// shardErrors is a channel where errors collected by calling
// collectResultAsync will be delivered. These results are meant to be
// inspected by calling waitForShard or checkShards, and the channel
// doesn't need to be initiated if the caller is using the sync
// collectResult directly.
// TODO(yy): delete.
shardErrors chan error
// quit is closed to signal the sub goroutines of the payment lifecycle
// to stop.
quit chan struct{}
wg sync.WaitGroup
// resultCollected is used to signal that the result of an attempt has
// been collected. A nil error means the attempt is either successful
// or failed with temporary error. Otherwise, we should exit the
// lifecycle loop as a terminal error has occurred.
resultCollected chan error
}
// newPaymentLifecycle initiates a new payment lifecycle and returns it.
@@ -53,14 +46,14 @@ func newPaymentLifecycle(r *ChannelRouter, feeLimit lnwire.MilliSatoshi,
currentHeight int32) *paymentLifecycle {
p := &paymentLifecycle{
router: r,
feeLimit: feeLimit,
identifier: identifier,
paySession: paySession,
shardTracker: shardTracker,
currentHeight: currentHeight,
shardErrors: make(chan error),
quit: make(chan struct{}),
router: r,
feeLimit: feeLimit,
identifier: identifier,
paySession: paySession,
shardTracker: shardTracker,
currentHeight: currentHeight,
quit: make(chan struct{}),
resultCollected: make(chan error, 1),
}
// If a timeout is specified, create a timeout channel. If no timeout is
@@ -92,6 +85,74 @@ func (p *paymentLifecycle) calcFeeBudget(
return budget
}
// stateStep defines an action to be taken in our payment lifecycle. We either
// quit, continue, or exit the lifecycle, see details below.
type stateStep uint8
const (
// stepSkip is used when we need to skip the current lifecycle and jump
// to the next one.
stepSkip stateStep = iota
// stepProceed is used when we can proceed the current lifecycle.
stepProceed
// stepExit is used when we need to quit the current lifecycle.
stepExit
)
// decideNextStep is used to determine the next step in the payment lifecycle.
func (p *paymentLifecycle) decideNextStep(
payment dbMPPayment) (stateStep, error) {
// Check whether we could make new HTLC attempts.
allow, err := payment.AllowMoreAttempts()
if err != nil {
return stepExit, err
}
if !allow {
// Check whether we need to wait for results.
wait, err := payment.NeedWaitAttempts()
if err != nil {
return stepExit, err
}
// If we are not allowed to make new HTLC attempts and there's
// no need to wait, the lifecycle is done and we can exit.
if !wait {
return stepExit, nil
}
log.Tracef("Waiting for attempt results for payment %v",
p.identifier)
// Otherwise we wait for one HTLC attempt then continue
// the lifecycle.
//
// NOTE: we don't check `p.quit` since `decideNextStep` is
// running in the same goroutine as `resumePayment`.
select {
case err := <-p.resultCollected:
// If an error is returned, exit with it.
if err != nil {
return stepExit, err
}
log.Tracef("Received attempt result for payment %v",
p.identifier)
case <-p.router.quit:
return stepExit, ErrRouterShuttingDown
}
return stepSkip, nil
}
// Otherwise we need to make more attempts.
return stepProceed, nil
}
// resumePayment resumes the paymentLifecycle from the current state.
func (p *paymentLifecycle) resumePayment() ([32]byte, *route.Route, error) {
// When the payment lifecycle loop exits, we make sure to signal any
@@ -127,20 +188,12 @@ func (p *paymentLifecycle) resumePayment() ([32]byte, *route.Route, error) {
// critical error during path finding.
lifecycle:
for {
// Start by quickly checking if there are any outcomes already
// available to handle before we reevaluate our state.
if err := p.checkShards(); err != nil {
return exitWithErr(err)
}
// We update the payment state on every iteration. Since the
// payment state is affected by multiple goroutines (ie,
// collectResultAsync), it is NOT guaranteed that we always
// have the latest state here. This is fine as long as the
// state is consistent as a whole.
// Fetch the latest payment from db.
payment, err := p.router.cfg.Control.FetchPayment(p.identifier)
payment, err = p.router.cfg.Control.FetchPayment(p.identifier)
if err != nil {
return exitWithErr(err)
}
@@ -153,53 +206,14 @@ lifecycle:
p.identifier, payment.Terminated(),
ps.NumAttemptsInFlight, ps.RemainingAmt, remainingFees)
// TODO(yy): sanity check all the states to make sure
// everything is expected.
// We have a terminal condition and no active shards, we are
// ready to exit.
if payment.Terminated() {
// Find the first successful shard and return
// the preimage and route.
for _, a := range payment.GetHTLCs() {
if a.Settle == nil {
continue
}
err := p.router.cfg.Control.DeleteFailedAttempts(
p.identifier,
)
if err != nil {
log.Errorf("Error deleting failed "+
"payment attempts for "+
"payment %v: %v", p.identifier,
err)
}
return a.Settle.Preimage, &a.Route, nil
}
// Payment failed.
return exitWithErr(*payment.GetFailureReason())
}
// If we either reached a terminal error condition (but had
// active shards still) or there is no remaining value to send,
// we'll wait for a shard outcome.
wait, err := payment.NeedWaitAttempts()
if err != nil {
return exitWithErr(err)
}
if wait {
// We still have outstanding shards, so wait for a new
// outcome to be available before re-evaluating our
// state.
if err := p.waitForShard(); err != nil {
return exitWithErr(err)
}
continue lifecycle
}
// We now proceed our lifecycle with the following tasks in
// order,
// 1. check timeout.
// 2. request route.
// 3. create HTLC attempt.
// 4. send HTLC attempt.
// 5. collect HTLC attempt result.
//
// Before we attempt any new shard, we'll check to see if
// either we've gone past the payment attempt timeout, or the
// router is exiting. In either case, we'll stop this payment
@@ -209,6 +223,30 @@ lifecycle:
return exitWithErr(err)
}
// Now decide the next step of the current lifecycle.
step, err := p.decideNextStep(payment)
if err != nil {
return exitWithErr(err)
}
switch step {
// Exit the for loop and return below.
case stepExit:
break lifecycle
// Continue the for loop and skip the rest.
case stepSkip:
continue lifecycle
// Continue the for loop and proceed the rest.
case stepProceed:
// Unknown step received, exit with an error.
default:
err = fmt.Errorf("unknown step: %v", step)
return exitWithErr(err)
}
// Now request a route to be used to create our HTLC attempt.
rt, err := p.requestRoute(ps)
if err != nil {
@@ -241,6 +279,27 @@ lifecycle:
p.collectResultAsync(attempt)
}
}
// Once we are out the lifecycle loop, it means we've reached a
// terminal condition. We either return the settled preimage or the
// payment's failure reason.
//
// Optionally delete the failed attempts from the database.
err = p.router.cfg.Control.DeleteFailedAttempts(p.identifier)
if err != nil {
log.Errorf("Error deleting failed htlc attempts for payment "+
"%v: %v", p.identifier, err)
}
// Find the first successful shard and return the preimage and route.
for _, a := range payment.GetHTLCs() {
if a.Settle != nil {
return a.Settle.Preimage, &a.Route, nil
}
}
// Otherwise return the payment failure reason.
return [32]byte{}, nil, *payment.GetFailureReason()
}
// checkTimeout checks whether the payment has reached its timeout.
@@ -332,46 +391,9 @@ func (p *paymentLifecycle) requestRoute(
return nil, nil
}
// stop signals any active shard goroutine to exit and waits for them to exit.
// stop signals any active shard goroutine to exit.
func (p *paymentLifecycle) stop() {
close(p.quit)
p.wg.Wait()
}
// waitForShard blocks until any of the outstanding shards return.
func (p *paymentLifecycle) waitForShard() error {
select {
case err := <-p.shardErrors:
return err
case <-p.quit:
return errShardHandlerExiting
case <-p.router.quit:
return ErrRouterShuttingDown
}
}
// checkShards is a non-blocking method that check if any shards has finished
// their execution.
func (p *paymentLifecycle) checkShards() error {
for {
select {
case err := <-p.shardErrors:
if err != nil {
return err
}
case <-p.quit:
return errShardHandlerExiting
case <-p.router.quit:
return ErrRouterShuttingDown
default:
return nil
}
}
}
// attemptResult holds the HTLC attempt and a possible error returned from
@@ -388,38 +410,33 @@ type attemptResult struct {
}
// collectResultAsync launches a goroutine that will wait for the result of the
// given HTLC attempt to be available then handle its result. It will fail the
// payment with the control tower if a terminal error is encountered.
// given HTLC attempt to be available then handle its result. Once received, it
// will send a nil error to channel `resultCollected` to indicate there's an
// result.
func (p *paymentLifecycle) collectResultAsync(attempt *channeldb.HTLCAttempt) {
// errToSend is the error to be sent to sh.shardErrors.
var errToSend error
// handleResultErr is a function closure must be called using defer. It
// finishes collecting result by updating the payment state and send
// the error (or nil) to sh.shardErrors.
handleResultErr := func() {
// Send the error or quit.
select {
case p.shardErrors <- errToSend:
case <-p.router.quit:
case <-p.quit:
}
p.wg.Done()
}
p.wg.Add(1)
go func() {
defer handleResultErr()
// Block until the result is available.
_, err := p.collectResult(attempt)
if err != nil {
log.Errorf("Error collecting result for attempt %v "+
"in payment %v: %v", attempt.AttemptID,
p.identifier, err)
}
errToSend = err
log.Debugf("Result collected for attempt %v in payment %v",
attempt.AttemptID, p.identifier)
// Once the result is collected, we signal it by writing the
// error to `resultCollected`.
select {
// Send the signal or quit.
case p.resultCollected <- err:
case <-p.quit:
log.Debugf("Lifecycle exiting while collecting "+
"result for payment %v", p.identifier)
case <-p.router.quit:
return
}
}()