faraday/itest/channel_events_test.go
bitromortac c5d7147ab0
itest: add channel events pruning test
Add an integration test to verify the safety pruning behavior under various
conditions.
2026-06-25 13:53:26 +02:00

435 lines
14 KiB
Go

package itest
import (
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/faraday/frdrpc"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TestGetChannelEvents pins the GetChannelEvents RPC contract: a regtest
// channel lifecycle surfaces the expected event-type counts, and a paginated
// walk over the same window matches the unpaginated result event-for-event.
func TestGetChannelEvents(t *testing.T) {
c := newTestContext(t)
defer c.stop()
ctx := context.Background()
// We will start by opening a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
aliceChannel, _ := c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Wait until alice can route a payment to bob through the new channel.
var paymentAmount lnwire.MilliSatoshi = 20000000
c.eventuallyf(func() bool {
return c.channelRoutable(c.bobPubkey, paymentAmount)
}, "channel did not become routable")
// Now we'll send a payment from alice to bob to generate a balance
// update event.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
c.makePayment(
c.aliceClient.LndServices, c.bobClient.LndServices,
lndclient.SendPaymentRequest{
Invoice: payreq,
PaymentHash: &hash,
Timeout: paymentTimeout,
}, lnrpc.Payment_SUCCEEDED,
)
// We now close the channel to generate an offline event.
c.closeChannel(c.aliceClient.Client, aliceChannel, true)
endTime := time.Now().Add(time.Second).Unix()
events, err := c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
},
)
require.NoError(c.t, err, "could not get channel events")
// Check that we have the expected event types.
var (
onlineEvents int
updateEvents int
offlineEvents int
)
for _, event := range events.Events {
switch event.EventType {
case frdrpc.ChannelEventType_CHAN_EVENT_ONLINE:
onlineEvents++
case frdrpc.ChannelEventType_CHAN_EVENT_UPDATE:
updateEvents++
case frdrpc.ChannelEventType_CHAN_EVENT_OFFLINE:
offlineEvents++
}
}
// We expect exactly these events for this channel:
// 1. Channel Open: online, update (initial balance)
// 2. Channel Active: online
// 3. Payment sent: two updates (update_add, update_fulfill)
// 4. Channel Offline: offline
// 5. Channel Close: offline
require.Len(t, events.Events, 7)
require.Equal(t, 2, onlineEvents)
require.Equal(t, 3, updateEvents)
require.Equal(t, 2, offlineEvents)
// Walk the same window with a small page size and assert the
// concatenated pages match the unpaginated result. Catches
// last_id round-trip, has_more termination, and MaxEvents
// clamping in one pass.
const pageSize = 2
var (
paged []*frdrpc.ChannelEvent
lastID int64
pages int
)
for {
page, err := c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
MaxEvents: pageSize,
LastId: lastID,
},
)
require.NoError(c.t, err, "could not get paginated events")
// Non-final pages must fill exactly pageSize; the final
// page (HasMore == false) holds the remainder.
if page.HasMore {
require.Len(t, page.Events, pageSize,
"non-final page %d not full", pages)
} else {
require.LessOrEqual(t, len(page.Events), pageSize,
"final page %d exceeds pageSize", pages)
}
paged = append(paged, page.Events...)
pages++
if !page.HasMore {
break
}
lastID = page.LastId
}
// With 7 events and pageSize 2 we expect ceil(7/2) = 4 pages.
require.Equal(t, 4, pages, "unexpected page count")
require.Equal(t, len(events.Events), len(paged),
"paginated and unpaginated counts differ")
for i, e := range events.Events {
require.Equal(t, e.Id, paged[i].Id,
"event order mismatch at index %d", i)
}
}
// TestForwardingAbility integration test opens a channel, sends payments to
// seed events, and verifies that calling the ForwardingAbility RPC returns
// the peer pair analytics successfully and can be decoded.
func TestForwardingAbility(t *testing.T) {
c := newTestContext(t)
defer c.stop()
ctx := context.Background()
// Connect nodes and open a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
_, _ = c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Wait until alice can route a payment to bob.
var paymentAmount lnwire.MilliSatoshi = 20000000
c.eventuallyf(func() bool {
return c.channelRoutable(c.bobPubkey, paymentAmount)
}, "channel did not become routable")
// Send a payment from alice to bob.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
c.makePayment(
c.aliceClient.LndServices, c.bobClient.LndServices,
lndclient.SendPaymentRequest{
Invoice: payreq,
PaymentHash: &hash,
Timeout: paymentTimeout,
}, lnrpc.Payment_SUCCEEDED,
)
// The alice->bob payment moved liquidity onto bob's side of the only
// channel, so from this point on the bob self-pair holds at least the
// requested floor. Measuring over a window that starts now keeps the
// pair's uptime fraction high, which both clears the uptime threshold
// and keeps the node-down guard satisfied. A future end time extends
// the window over the still-funded state.
bobHex := c.bobPubkey.String()
windowStart := time.Now()
// The events store ingests channel updates asynchronously, so retry
// until the bob self-pair surfaces.
var ability frdrpc.ForwardingAbility
c.eventuallyf(func() bool {
endTime := time.Now().Add(2 * time.Second).Unix()
resp, err := c.faradayClient.ForwardingAbility(
ctx, &frdrpc.ForwardingAbilityRequest{
StartTime: uint64(windowStart.Unix()),
EndTime: uint64(endTime),
LiquidityFloorSat: 1000,
UptimeThreshold: 0.1,
},
)
if err != nil {
return false
}
decoded, err := frdrpc.DecodeForwardingAbility(resp)
if err != nil {
return false
}
a, ok := decoded[bobHex][bobHex]
if !ok {
return false
}
ability = a
return true
}, "expected bob self-pair in forwarding ability")
// The bob self-pair was up but never forwarded through itself, so it
// surfaces via the up-but-idle bitmask: non-zero effective uptime and
// zero forwarded volume.
require.Greater(c.t, ability.EffectiveUptimeS, int64(0))
require.Zero(c.t, ability.ForwardedSat)
}
// TestForwardingDowntime exercises the offline/online plumbing end to end. It
// disconnects the only channel peer to take the channel offline, asserts that
// faraday records the resulting offline event, then reconnects the peer and
// asserts the recovering online event lands and the bob self-pair surfaces in
// ForwardingAbility again. This proves downtime and recovery flow through to
// the analyzer; the exact per-second uptime math is covered deterministically
// by the analyzer unit tests.
func TestForwardingDowntime(t *testing.T) {
c := newTestContext(t)
defer c.stop()
ctx := context.Background()
// Connect nodes and open a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
aliceChannel, _ := c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Wait until alice can route a payment to bob.
var paymentAmount lnwire.MilliSatoshi = 20000000
c.eventuallyf(func() bool {
return c.channelRoutable(c.bobPubkey, paymentAmount)
}, "channel did not become routable")
// Move liquidity onto bob's side so the bob self-pair clears the
// liquidity floor while the channel is up.
hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount)
c.makePayment(
c.aliceClient.LndServices, c.bobClient.LndServices,
lndclient.SendPaymentRequest{
Invoice: payreq,
PaymentHash: &hash,
Timeout: paymentTimeout,
}, lnrpc.Payment_SUCCEEDED,
)
chanPoint := aliceChannel.String()
bobHex := c.bobPubkey.String()
// Snapshot the event counts before the disconnect so we can detect the
// new offline and online events the disconnect and recovery produce.
onlineBefore, offlineBefore := c.channelEventCounts(chanPoint)
// Disconnect bob to take the only channel offline.
c.disconnectPeer(c.aliceClient, c.bobPubkey)
// faraday should ingest the resulting offline event: this is the
// downtime signal that the channel went inactive.
c.eventuallyf(func() bool {
_, offline := c.channelEventCounts(chanPoint)
return offline > offlineBefore
}, "expected an offline event after disconnect")
// An explicit DisconnectPeer is sticky: lnd does not auto-reconnect, so
// the channel stays offline until we reconnect. A window that sits
// entirely in this offline period leaves no pair clearing the uptime
// threshold, so the node-down guard rejects the request with
// FailedPrecondition rather than returning an empty response. The
// threshold is irrelevant here since the only pair has zero uptime.
c.eventuallyf(func() bool {
now := time.Now()
_, err := c.faradayClient.ForwardingAbility(
ctx, &frdrpc.ForwardingAbilityRequest{
StartTime: uint64(now.Unix()),
EndTime: uint64(
now.Add(2 * time.Second).Unix(),
),
LiquidityFloorSat: 1000,
UptimeThreshold: 0.9,
},
)
return status.Code(err) == codes.FailedPrecondition
}, "expected node-down guard while bob is disconnected")
// An explicit DisconnectPeer drops lnd's persistent connection, so the
// channel only comes back up once we reconnect. Reconnect bob to bring
// the channel active again.
err = c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not reconnect nodes")
// The channel goes active again on reconnect, which faraday records as
// an online event.
c.eventuallyf(func() bool {
online, _ := c.channelEventCounts(chanPoint)
return online > onlineBefore
}, "expected an online event after reconnect")
// With the channel back up and liquidity still on bob's side, the bob
// self-pair surfaces in ForwardingAbility again over a fresh window
// that opens after recovery.
c.eventuallyf(func() bool {
now := time.Now()
resp, err := c.faradayClient.ForwardingAbility(
ctx, &frdrpc.ForwardingAbilityRequest{
StartTime: uint64(now.Unix()),
EndTime: uint64(
now.Add(2 * time.Second).Unix(),
),
LiquidityFloorSat: 1000,
UptimeThreshold: 0.1,
},
)
if err != nil {
return false
}
decoded, err := frdrpc.DecodeForwardingAbility(resp)
if err != nil {
return false
}
_, ok := decoded[bobHex][bobHex]
return ok
}, "expected bob self-pair after reconnect")
}
// TestChannelEventsPruning verifies that starting Faraday with low size limits
// (e.g. max-events=1 and retention=2s) executes live background pruning
// successfully and bounds the database size correctly.
func TestChannelEventsPruning(t *testing.T) {
c := newTestContext(
t, "--chanevents.max-events=1", "--chanevents.retention=2s",
)
defer c.stop()
ctx := context.Background()
// We will start by opening a channel from alice to bob.
var aliceChannelAmt = btcutil.Amount(500000)
err := c.aliceClient.Client.Connect(
ctx, c.bobPubkey, "localhost:10012", true,
)
require.NoError(c.t, err, "could not connect nodes")
aliceChannel, _ := c.openChannel(
c.aliceClient.Client, c.bobPubkey, aliceChannelAmt,
)
// Use a far-future end time so the query window never excludes a stored
// event on a slow host. A tight wall-clock window here would make the
// counts below racy.
endTime := time.Now().Add(time.Hour).Unix()
// We deliberately do not assert on the initial event count here: opening
// a channel records several events, but the 2-second background prune can
// fire before we observe them on a slow host, so any such pre-prune
// assertion would be flaky. The eventuallyf checks below verify the
// pruning behaviour directly instead.
// Wait for the live background pruning ticker to bound the table to the
// max-events ceiling. We assert at most one event rather than exactly
// one: the size limit keeps a single event, but the 2-second retention
// limit then ages it out since no new events follow the channel open,
// so the steady state is zero or one.
var eventsAfter *frdrpc.ChannelEventsResponse
c.eventuallyf(func() bool {
var err error
eventsAfter, err = c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
},
)
if err != nil {
return false
}
return len(eventsAfter.Events) <= 1
}, "expected channel events to be pruned down to at most one in the "+
"background")
// No further events follow the channel open, so once the remaining
// event ages past the 2-second retention window the age-based prune
// removes it too, draining the table to zero.
c.eventuallyf(func() bool {
eventsAfter, err := c.faradayClient.GetChannelEvents(
ctx, &frdrpc.ChannelEventsRequest{
ChanPoint: aliceChannel.String(),
EndTime: endTime,
},
)
if err != nil {
return false
}
return len(eventsAfter.Events) == 0
}, "expected channel events to be pruned down to zero once all events "+
"age out of the retention window")
}