channeldb: add tests for DeleteForwardingEvents
What changed, and why it matters
This commit only adds new automated tests for an existing database cleanup feature called DeleteForwardingEvents. It does not change any production code, so it cannot introduce a security vulnerability or fix one directly. The tests verify that old forwarding records are deleted correctly, in batches, and that the operation is idempotent.
No security action required; review as normal test-only code.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff is limited to channeldb/forwarding_log_test.go and adds unit tests and property-based tests (using pgregory.net/rapid) for the ForwardingLog.DeleteForwardingEvents method. No implementation code is modified. Tests cover full deletion, partial deletion by time range, batching, empty database handling, exact boundary conditions, max batch size capping, idempotency, and randomized invariant checks for event counts, fee totals, and time boundaries.
Changed components
channeldb/forwarding_log_test.goInspect captured patch +524 / −0
diff --git a/channeldb/forwarding_log_test.go b/channeldb/forwarding_log_test.go
index ad80717..950b4cb 100644
--- a/channeldb/forwarding_log_test.go
+++ b/channeldb/forwarding_log_test.go
@@ -13,6 +13,7 @@ import (
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "pgregory.net/rapid"
)
// TestForwardingLogBasicStorageAndQuery tests that we're able to store and
@@ -606,3 +607,526 @@ func TestForwardingLogQueryChanIDs(t *testing.T) {
})
}
}
+
+// TestForwardingLogDeletion tests the basic deletion functionality of the
+// forwarding log.
+func TestForwardingLogDeletion(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ // Create 50 events spanning 500 minutes (10 min intervals).
+ initialTime := time.Unix(1000, 0)
+ timestamp := initialTime
+ numEvents := 50
+ events := make([]ForwardingEvent, numEvents)
+
+ var expectedTotalFees int64
+ for i := range numEvents {
+ amtIn := lnwire.MilliSatoshi(10000 + rand.Intn(5000))
+ amtOut := lnwire.MilliSatoshi(9000 + rand.Intn(4000))
+ events[i] = ForwardingEvent{
+ Timestamp: timestamp,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(uint64(i)),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(
+ uint64(i + 100),
+ ),
+ AmtIn: amtIn,
+ AmtOut: amtOut,
+ IncomingHtlcID: fn.Some(uint64(i)),
+ OutgoingHtlcID: fn.Some(uint64(i)),
+ }
+ expectedTotalFees += int64(amtIn - amtOut)
+ timestamp = timestamp.Add(time.Minute * 10)
+ }
+
+ // Add all events to the database.
+ err = log.AddForwardingEvents(events)
+ require.NoError(t, err, "unable to add events")
+
+ // Delete all events (use timestamp after the last event).
+ deleteTime := timestamp.Add(time.Minute)
+ stats, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, 0,
+ )
+ require.NoError(t, err, "unable to delete events")
+
+ // Verify statistics.
+ require.Equal(t, uint64(numEvents), stats.NumEventsDeleted,
+ "wrong number of events deleted")
+ require.Equal(t, expectedTotalFees, stats.TotalFeeMsat,
+ "wrong total fees")
+
+ // Verify all events were deleted by querying.
+ query := ForwardingEventQuery{
+ StartTime: initialTime,
+ EndTime: timestamp,
+ NumMaxEvents: 1000,
+ }
+ result, err := log.Query(query)
+ require.NoError(t, err, "query failed")
+ require.Empty(t, result.ForwardingEvents, "events should be deleted")
+}
+
+// TestForwardingLogPartialDeletion tests that we can delete a subset of events
+// based on time.
+func TestForwardingLogPartialDeletion(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ initialTime := time.Unix(2000, 0)
+ timestamp := initialTime
+ numEvents := 100
+ events := make([]ForwardingEvent, numEvents)
+
+ for i := range numEvents {
+ events[i] = ForwardingEvent{
+ Timestamp: timestamp,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(uint64(i)),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(
+ uint64(i + 100),
+ ),
+ AmtIn: lnwire.MilliSatoshi(10000),
+ AmtOut: lnwire.MilliSatoshi(9500),
+ IncomingHtlcID: fn.Some(uint64(i)),
+ OutgoingHtlcID: fn.Some(uint64(i)),
+ }
+ timestamp = timestamp.Add(time.Minute * 10)
+ }
+
+ err = log.AddForwardingEvents(events)
+ require.NoError(t, err, "unable to add events")
+
+ // Delete only the first 50 events (events 0-49). The 50th event is at
+ // initialTime + 50*10 minutes.
+ deleteTime := events[49].Timestamp.Add(time.Nanosecond)
+ stats, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, 0,
+ )
+ require.NoError(t, err, "unable to delete events")
+
+ // Should have deleted exactly 50 events.
+ require.Equal(
+ t, uint64(50), stats.NumEventsDeleted,
+ "wrong number of events deleted",
+ )
+
+ // Fee per event is 500 msat, so total should be 50 * 500 = 25000.
+ require.Equal(t, int64(25000), stats.TotalFeeMsat, "wrong total fees")
+
+ // Query to verify remaining events (should be 50 events left).
+ query := ForwardingEventQuery{
+ StartTime: initialTime,
+ EndTime: timestamp,
+ NumMaxEvents: 1000,
+ }
+ result, err := log.Query(query)
+ require.NoError(t, err, "query failed")
+ require.Len(
+ t, result.ForwardingEvents, 50,
+ "wrong number of remaining events",
+ )
+
+ // The remaining events should be events[50:].
+ require.Equal(
+ t, events[50:], result.ForwardingEvents,
+ "wrong events remaining",
+ )
+}
+
+// TestForwardingLogBatchDeletion tests that deletion works correctly with
+// different batch sizes.
+func TestForwardingLogBatchDeletion(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ initialTime := time.Unix(3000, 0)
+ timestamp := initialTime
+ numEvents := 250
+ events := make([]ForwardingEvent, numEvents)
+
+ for i := range numEvents {
+ events[i] = ForwardingEvent{
+ Timestamp: timestamp,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(uint64(i)),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(
+ uint64(i + 100),
+ ),
+ AmtIn: lnwire.MilliSatoshi(10000),
+ AmtOut: lnwire.MilliSatoshi(9000),
+ IncomingHtlcID: fn.Some(uint64(i)),
+ OutgoingHtlcID: fn.Some(uint64(i)),
+ }
+ timestamp = timestamp.Add(time.Minute)
+ }
+
+ err = log.AddForwardingEvents(events)
+ require.NoError(t, err, "unable to add events")
+
+ // Delete with a small batch size to test multiple batches.
+ deleteTime := timestamp.Add(time.Minute)
+ stats, err := log.DeleteForwardingEvents(t.Context(), deleteTime, 75)
+ require.NoError(t, err, "unable to delete events")
+
+ // Should have deleted all events across multiple batches.
+ require.Equal(t, uint64(numEvents), stats.NumEventsDeleted,
+ "wrong number of events deleted")
+
+ // Fee per event is 1000 msat.
+ expectedFees := int64(numEvents * 1000)
+ require.Equal(t, expectedFees, stats.TotalFeeMsat, "wrong total fees")
+
+ // Verify all deleted.
+ query := ForwardingEventQuery{
+ StartTime: initialTime,
+ EndTime: timestamp,
+ NumMaxEvents: 1000,
+ }
+ result, err := log.Query(query)
+ require.NoError(t, err, "query failed")
+ require.Empty(t, result.ForwardingEvents, "events should be deleted")
+}
+
+// TestForwardingLogDeleteEmpty tests deletion on an empty database.
+func TestForwardingLogDeleteEmpty(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ // Try to delete from empty database.
+ deleteTime := time.Now()
+ stats, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, 0,
+ )
+ require.NoError(t, err, "delete should not error on empty db")
+
+ // Should have deleted 0 events with 0 fees.
+ require.Equal(
+ t, uint64(0), stats.NumEventsDeleted,
+ "should delete 0 events",
+ )
+ require.Equal(
+ t, int64(0), stats.TotalFeeMsat, "should have 0 fees",
+ )
+}
+
+// TestForwardingLogDeleteTimeBoundary tests deletion at exact time boundaries.
+func TestForwardingLogDeleteTimeBoundary(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ baseTime := time.Unix(5000, 0)
+ events := []ForwardingEvent{
+ {
+ Timestamp: baseTime,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(1),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(101),
+ AmtIn: 10000,
+ AmtOut: 9000,
+ IncomingHtlcID: fn.Some(uint64(0)),
+ OutgoingHtlcID: fn.Some(uint64(0)),
+ },
+ {
+ Timestamp: baseTime.Add(time.Hour),
+ IncomingChanID: lnwire.NewShortChanIDFromInt(2),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(102),
+ AmtIn: 10000,
+ AmtOut: 9000,
+ IncomingHtlcID: fn.Some(uint64(1)),
+ OutgoingHtlcID: fn.Some(uint64(1)),
+ },
+ {
+ Timestamp: baseTime.Add(2 * time.Hour),
+ IncomingChanID: lnwire.NewShortChanIDFromInt(3),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(103),
+ AmtIn: 10000,
+ AmtOut: 9000,
+ IncomingHtlcID: fn.Some(uint64(2)),
+ OutgoingHtlcID: fn.Some(uint64(2)),
+ },
+ }
+
+ err = log.AddForwardingEvents(events)
+ require.NoError(t, err, "unable to add events")
+
+ // Delete events at exactly the second event's timestamp. This should
+ // delete events at baseTime and baseTime+1h.
+ deleteTime := baseTime.Add(time.Hour)
+ stats, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, 0,
+ )
+ require.NoError(t, err, "unable to delete events")
+
+ // Should delete exactly 2 events (those at or before deleteTime).
+ require.Equal(
+ t, uint64(2), stats.NumEventsDeleted,
+ "wrong number of events deleted",
+ )
+
+ query := ForwardingEventQuery{
+ StartTime: baseTime,
+ EndTime: baseTime.Add(3 * time.Hour),
+ NumMaxEvents: 10,
+ }
+ result, err := log.Query(query)
+ require.NoError(t, err, "query failed")
+
+ // We should have 1 event remaining.
+ require.Len(
+ t, result.ForwardingEvents, 1, "wrong number remaining",
+ )
+ require.Equal(
+ t, events[2], result.ForwardingEvents[0],
+ "wrong event remaining",
+ )
+}
+
+// TestForwardingLogDeleteMaxBatchSize tests that the max batch size is
+// enforced.
+func TestForwardingLogDeleteMaxBatchSize(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ // Create some events.
+ initialTime := time.Unix(6000, 0)
+ events := []ForwardingEvent{
+ {
+ Timestamp: initialTime,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(1),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(101),
+ AmtIn: 10000,
+ AmtOut: 9000,
+ IncomingHtlcID: fn.Some(uint64(0)),
+ OutgoingHtlcID: fn.Some(uint64(0)),
+ },
+ }
+
+ err = log.AddForwardingEvents(events)
+ require.NoError(t, err, "unable to add events")
+
+ // Try to delete with a batch size larger than MaxResponseEvents.
+ deleteTime := initialTime.Add(time.Hour)
+ stats, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, MaxResponseEvents+1000,
+ )
+ require.NoError(t, err, "delete should succeed")
+
+ // Should have deleted the event (batch size should be capped).
+ require.Equal(
+ t, uint64(1), stats.NumEventsDeleted, "event should be deleted",
+ )
+}
+
+// TestForwardingLogDeleteIdempotent tests that deletion is idempotent.
+func TestForwardingLogDeleteIdempotent(t *testing.T) {
+ t.Parallel()
+
+ db, err := MakeTestDB(t)
+ require.NoError(t, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ // Create events.
+ initialTime := time.Unix(7000, 0)
+ timestamp := initialTime
+ events := make([]ForwardingEvent, 10)
+ for i := range 10 {
+ events[i] = ForwardingEvent{
+ Timestamp: timestamp,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(uint64(i)),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(
+ uint64(i + 100),
+ ),
+ AmtIn: lnwire.MilliSatoshi(10000),
+ AmtOut: lnwire.MilliSatoshi(9000),
+ IncomingHtlcID: fn.Some(uint64(i)),
+ OutgoingHtlcID: fn.Some(uint64(i)),
+ }
+ timestamp = timestamp.Add(time.Minute)
+ }
+
+ err = log.AddForwardingEvents(events)
+ require.NoError(t, err, "unable to add events")
+
+ deleteTime := timestamp
+ stats1, err := log.DeleteForwardingEvents(t.Context(), deleteTime, 0)
+ require.NoError(t, err, "first delete failed")
+ require.Equal(t, uint64(10), stats1.NumEventsDeleted)
+
+ // Delete again with same time - should delete 0 events.
+ stats2, err := log.DeleteForwardingEvents(t.Context(), deleteTime, 0)
+ require.NoError(t, err, "second delete failed")
+ require.Equal(
+ t, uint64(0), stats2.NumEventsDeleted, "should be idempotent",
+ )
+ require.Equal(t, int64(0), stats2.TotalFeeMsat, "should have no fees")
+}
+
+// TestForwardingLogDeleteInvariants uses property-based testing to verify key
+// invariants of the deletion logic.
+func TestForwardingLogDeleteInvariants(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ db, err := MakeTestDB(t)
+ require.NoError(rt, err, "unable to make test db")
+
+ log := ForwardingLog{
+ db: db,
+ }
+
+ // Generate a random set of events.
+ baseTime := time.Unix(
+ rapid.Int64Range(10000, 100000).Draw(rt, "base_time"),
+ 0,
+ )
+ numEvents := rapid.IntRange(1, 100).Draw(rt, "num_events")
+
+ events := make([]ForwardingEvent, numEvents)
+ timestamp := baseTime
+ for i := range numEvents {
+ amtIn := rapid.Uint64Range(1000, 100000).
+ Draw(rt, "amt_in")
+
+ amtOut := rapid.Uint64Range(500, amtIn).
+ Draw(rt, "amt_out")
+
+ events[i] = ForwardingEvent{
+ Timestamp: timestamp,
+ IncomingChanID: lnwire.NewShortChanIDFromInt(
+ rapid.Uint64().Draw(rt, "in_chan"),
+ ),
+ OutgoingChanID: lnwire.NewShortChanIDFromInt(
+ rapid.Uint64().Draw(rt, "out_chan"),
+ ),
+ AmtIn: lnwire.MilliSatoshi(amtIn),
+ AmtOut: lnwire.MilliSatoshi(amtOut),
+ IncomingHtlcID: fn.Some(uint64(i)),
+ OutgoingHtlcID: fn.Some(uint64(i)),
+ }
+ // Add random interval between events (1 second to 1
+ // hour).
+ interval := rapid.Int64Range(1, 3600).Draw(
+ rt, "interval",
+ )
+ timestamp = timestamp.Add(
+ time.Duration(interval) * time.Second,
+ )
+ }
+
+ // Add events to database.
+ err = log.AddForwardingEvents(events)
+ require.NoError(rt, err, "unable to add events")
+
+ // Pick a random delete time somewhere in the middle or after.
+ // This gives us a mix of partial and full deletions.
+ deleteIndex := rapid.IntRange(0, numEvents).Draw(
+ rt, "delete_index",
+ )
+
+ var deleteTime time.Time
+ if deleteIndex < numEvents {
+ deleteTime = events[deleteIndex].Timestamp
+ } else {
+ deleteTime = timestamp.Add(time.Hour)
+ }
+
+ // Pick a random batch size, then delete with that batch size.
+ batchSize := rapid.IntRange(1, 100).Draw(rt, "batch_size")
+ stats, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, batchSize,
+ )
+ require.NoError(rt, err, "delete failed")
+
+ // Invariant 1: Number of deleted events should match count
+ // before delete time.
+ expectedDeleted := 0
+ var expectedFees int64
+ for _, event := range events {
+ if event.Timestamp.Before(deleteTime) ||
+ event.Timestamp.Equal(deleteTime) {
+
+ expectedDeleted++
+
+ expectedFees += int64(
+ event.AmtIn - event.AmtOut,
+ )
+ }
+ }
+ require.Equal(
+ rt, uint64(expectedDeleted), stats.NumEventsDeleted,
+ "deleted count doesn't match",
+ )
+
+ // Invariant 2: Total fees should equal sum of deleted event
+ // fees.
+ require.Equal(rt, expectedFees, stats.TotalFeeMsat,
+ "total fees don't match")
+
+ // Invariant 3: Query should only return events after delete
+ // time.
+ query := ForwardingEventQuery{
+ StartTime: baseTime,
+ EndTime: timestamp.Add(time.Hour),
+ NumMaxEvents: uint32(numEvents * 2),
+ }
+ result, err := log.Query(query)
+ require.NoError(rt, err, "query failed")
+
+ expectedRemaining := numEvents - expectedDeleted
+ require.Len(rt, result.ForwardingEvents, expectedRemaining,
+ "wrong number of remaining events")
+
+ // Invariant 4: All remaining events should be after delete
+ // time.
+ for _, event := range result.ForwardingEvents {
+ require.True(rt, event.Timestamp.After(deleteTime),
+ "remaining event is not after delete time: "+
+ "%v <= %v", event.Timestamp, deleteTime)
+ }
+
+ // Invariant 5: Second deletion should be idempotent.
+ stats2, err := log.DeleteForwardingEvents(
+ t.Context(), deleteTime, batchSize,
+ )
+ require.NoError(rt, err, "second delete failed")
+ require.Equal(rt, uint64(0), stats2.NumEventsDeleted,
+ "second delete should delete nothing")
+ require.Equal(rt, int64(0), stats2.TotalFeeMsat,
+ "second delete should have no fees")
+ })
+}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.