Skip to content

Commit 6fd5b7b

Browse files
authored
Merge pull request #10780 from ziggie1984/channeldb-tombstone-close
channeldb: tombstone closed channels on KV-SQL backends
2 parents 8df972d + 1a52e85 commit 6fd5b7b

8 files changed

Lines changed: 816 additions & 110 deletions

File tree

channeldb/channel.go

Lines changed: 240 additions & 96 deletions
Large diffs are not rendered by default.

channeldb/close_channel_test.go

Lines changed: 406 additions & 0 deletions
Large diffs are not rendered by default.

channeldb/db.go

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,8 @@ func CreateWithBackend(backend kvdb.Backend, modifiers ...OptionModifier) (*DB,
418418
linkNodeDB: &LinkNodeDB{
419419
backend: backend,
420420
},
421-
backend: backend,
421+
backend: backend,
422+
tombstoneClosedChannels: opts.tombstoneClosedChannels,
422423
},
423424
clock: opts.clock,
424425
dryRun: opts.dryRun,
@@ -548,6 +549,12 @@ type ChannelStateDB struct {
548549
// backend points to the actual backend holding the channel state
549550
// database. This may be a real backend or a cache middleware.
550551
backend kvdb.Backend
552+
553+
// tombstoneClosedChannels is set by OptionTombstoneClosedChannels.
554+
// When true, CloseChannel skips deleting nested per-channel state and
555+
// relies on the outpointBucket flip to outpointClosed as the
556+
// authoritative closed-channel signal.
557+
tombstoneClosedChannels bool
551558
}
552559

553560
// GetParentDB returns the "main" channeldb.DB object that is the owner of this
@@ -621,7 +628,7 @@ func (c *ChannelStateDB) fetchOpenChannels(tx kvdb.RTx,
621628

622629
// Finally, we both of the necessary buckets retrieved, fetch
623630
// all the active channels related to this node.
624-
nodeChannels, err := c.fetchNodeChannels(chainBucket)
631+
nodeChannels, err := c.fetchNodeChannels(tx, chainBucket)
625632
if err != nil {
626633
return fmt.Errorf("unable to read channel for "+
627634
"chain_hash=%x, node_key=%x: %v",
@@ -637,12 +644,19 @@ func (c *ChannelStateDB) fetchOpenChannels(tx kvdb.RTx,
637644

638645
// fetchNodeChannels retrieves all active channels from the target chainBucket
639646
// which is under a node's dedicated channel bucket. This function is typically
640-
// used to fetch all the active channels related to a particular node.
641-
func (c *ChannelStateDB) fetchNodeChannels(chainBucket kvdb.RBucket) (
642-
[]*OpenChannel, error) {
647+
// used to fetch all the active channels related to a particular node. Channels
648+
// already flipped to outpointClosed in the outpoint index are skipped silently
649+
// — readers see only channels that are still considered open.
650+
func (c *ChannelStateDB) fetchNodeChannels(tx kvdb.RTx,
651+
chainBucket kvdb.RBucket) ([]*OpenChannel, error) {
643652

644653
var channels []*OpenChannel
645654

655+
// Hoist the outpoint-bucket lookup so the closed-channel check inside
656+
// the loop is a per-iteration map probe rather than a tx-level bucket
657+
// resolve.
658+
opBucket := tx.ReadBucket(outpointBucket)
659+
646660
// A node may have channels on several chains, so for each known chain,
647661
// we'll extract all the channels.
648662
err := chainBucket.ForEach(func(chanPoint, v []byte) error {
@@ -651,12 +665,24 @@ func (c *ChannelStateDB) fetchNodeChannels(chainBucket kvdb.RBucket) (
651665
return nil
652666
}
653667

668+
// Skip already-closed channels. The chanBucket still exists
669+
// on disk on tombstone-enabled backends; the outpoint flip is
670+
// the sole signal that the channel should be treated as
671+
// closed.
672+
isClosed, err := isOutpointClosed(opBucket, chanPoint)
673+
if err != nil {
674+
return err
675+
}
676+
if isClosed {
677+
return nil
678+
}
679+
654680
// Once we've found a valid channel bucket, we'll extract it
655681
// from the node's chain bucket.
656682
chanBucket := chainBucket.NestedReadBucket(chanPoint)
657683

658684
var outPoint wire.OutPoint
659-
err := graphdb.ReadOutpoint(
685+
err = graphdb.ReadOutpoint(
660686
bytes.NewReader(chanPoint), &outPoint,
661687
)
662688
if err != nil {
@@ -771,6 +797,11 @@ func (c *ChannelStateDB) FetchPermAndTempPeers(
771797
return ErrNoChanDBExists
772798
}
773799

800+
// Hoist the outpoint-bucket lookup so the closed-channel check
801+
// inside the nested chainBucket.ForEach below is a per-channel
802+
// map probe rather than a tx-level bucket resolve.
803+
opBucket := tx.ReadBucket(outpointBucket)
804+
774805
openChanErr := openChanBucket.ForEach(func(nodePub,
775806
v []byte) error {
776807

@@ -804,6 +835,22 @@ func (c *ChannelStateDB) FetchPermAndTempPeers(
804835
return nil
805836
}
806837

838+
// Skip already-closed channels: they are
839+
// logically closed even though their
840+
// per-channel state still resides under
841+
// chainBucket. The closed peer's protected
842+
// status is established below via the
843+
// historical-channel scan.
844+
isClosed, err := isOutpointClosed(
845+
opBucket, chanPoint,
846+
)
847+
if err != nil {
848+
return err
849+
}
850+
if isClosed {
851+
return nil
852+
}
853+
807854
chanBucket := chainBucket.NestedReadBucket(
808855
chanPoint,
809856
)
@@ -976,6 +1023,11 @@ func (c *ChannelStateDB) channelScanner(tx kvdb.RTx,
9761023
return ErrNoActiveChannels
9771024
}
9781025

1026+
// Hoist the outpoint-bucket lookup so the closed-channel
1027+
// check inside the per-chain ForEach below pays one tx-level
1028+
// bucket resolve total instead of one per visited chanKey.
1029+
opBucket := tx.ReadBucket(outpointBucket)
1030+
9791031
// Within the node channel bucket, are the set of node pubkeys
9801032
// we have channels with, we don't know the entire set, so we'll
9811033
// check them all.
@@ -1024,6 +1076,19 @@ func (c *ChannelStateDB) channelScanner(tx kvdb.RTx,
10241076
return err
10251077
}
10261078

1079+
// An already-closed channel is logically gone
1080+
// and must not be surfaced by lookup-style
1081+
// scans.
1082+
isClosed, err := isOutpointClosed(
1083+
opBucket, targetChanBytes,
1084+
)
1085+
if err != nil {
1086+
return err
1087+
}
1088+
if isClosed {
1089+
return nil
1090+
}
1091+
10271092
chanBucket := chainBucket.NestedReadBucket(
10281093
targetChanBytes,
10291094
)
@@ -1187,7 +1252,9 @@ func fetchChannels(c *ChannelStateDB, filters ...fetchChannelsFilter) (
11871252
"bucket for chain=%x", chainHash[:])
11881253
}
11891254

1190-
nodeChans, err := c.fetchNodeChannels(chainBucket)
1255+
nodeChans, err := c.fetchNodeChannels(
1256+
tx, chainBucket,
1257+
)
11911258
if err != nil {
11921259
return fmt.Errorf("unable to read "+
11931260
"channel for chain_hash=%x, "+

channeldb/options.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ type Options struct {
7171
// storeFinalHtlcResolutions determines whether to persistently store
7272
// the final resolution of incoming htlcs.
7373
storeFinalHtlcResolutions bool
74+
75+
// tombstoneClosedChannels, when true, instructs CloseChannel to skip
76+
// the cascading deletion of nested per-channel state and rely on the
77+
// outpoint-index flip to mark the channel as closed. KV-over-SQL
78+
// backends (sqlite, postgres) opt in because nested-bucket deletes
79+
// inside a write transaction translate into a long-running
80+
// ON DELETE CASCADE that holds the database write-lock for many
81+
// seconds on long-lived channels. bbolt and etcd leave this off; the
82+
// synchronous delete is already cheap there.
83+
tombstoneClosedChannels bool
7484
}
7585

7686
// DefaultOptions returns an Options populated with default values.
@@ -151,3 +161,14 @@ func OptionGcDecayedLog(noGc bool) OptionModifier {
151161
o.OptionalMiragtionConfig.MigrationFlags[1] = !noGc
152162
}
153163
}
164+
165+
// OptionTombstoneClosedChannels controls whether CloseChannel skips the
166+
// cascading deletion of nested per-channel state and relies on the
167+
// outpoint-index flip to mark the channel as closed. Set this to true on
168+
// KV-over-SQL backends (sqlite, postgres); leave it false for bbolt and
169+
// etcd.
170+
func OptionTombstoneClosedChannels(enabled bool) OptionModifier {
171+
return func(o *Options) {
172+
o.tombstoneClosedChannels = enabled
173+
}
174+
}

config_builder.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,15 @@ func (d *DefaultDatabaseBuilder) BuildDatabase(
10721072
)
10731073
}
10741074

1075+
// KV-over-SQL backends (sqlite, postgres) opt in to closing channels
1076+
// via tombstone markers because nested-bucket deletes inside a write
1077+
// transaction translate into a long-running ON DELETE CASCADE on the
1078+
// kvdb-on-SQL schema, holding the database write-lock for many seconds
1079+
// on long-lived channels. bbolt and etcd keep the synchronous one-shot
1080+
// close path, where nested-bucket deletion is already cheap.
1081+
tombstoneClosedChans := cfg.DB.Backend == lncfg.SqliteBackend ||
1082+
cfg.DB.Backend == lncfg.PostgresBackend
1083+
10751084
dbOptions := []channeldb.OptionModifier{
10761085
channeldb.OptionDryRunMigration(cfg.DryRunMigration),
10771086
channeldb.OptionStoreFinalHtlcResolutions(
@@ -1081,6 +1090,7 @@ func (d *DefaultDatabaseBuilder) BuildDatabase(
10811090
channeldb.OptionNoRevLogAmtData(cfg.DB.NoRevLogAmtData),
10821091
channeldb.OptionGcDecayedLog(cfg.DB.NoGcDecayedLog),
10831092
channeldb.OptionWithDecayedLogDB(dbs.DecayedLogDB),
1093+
channeldb.OptionTombstoneClosedChannels(tombstoneClosedChans),
10841094
}
10851095

10861096
// Otherwise, we'll open two instances, one for the state we only need

docs/release-notes/release-notes-0.21.0.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,37 @@
296296
public-only path sees a ~42% speedup; on the previous code it could stall
297297
for minutes.
298298

299+
* [Tombstone closed channels on KV-over-SQL
300+
backends](https://github.com/lightningnetwork/lnd/pull/10780). Closing a
301+
long-lived channel previously issued a single `DeleteNestedBucket` inside
302+
the close transaction. On the kvdb-on-SQL schema (sqlite, postgres) that
303+
delete fans out into a row-by-row `ON DELETE CASCADE` over the channel's
304+
revocation log and forwarding-package bucket, holding the database
305+
write-lock for many seconds — long enough on channels with millions of
306+
states to stall HTLC forwarding, time out htlcswitch retries, and trigger
307+
force-close cycles. `CloseChannel` now skips the cascading delete on
308+
these backends; the outpoint-index flip from `outpointOpen` to
309+
`outpointClosed` (already performed by the existing close path) is the
310+
authoritative closed-channel marker, and every reader of the open-channel
311+
bucket consults it before treating a channel as open. The bulk historical
312+
state — the chanBucket itself, the revocation log, and the per-channel
313+
forwarding-package bucket — remains on disk for the channel's lifetime in
314+
this database and is reclaimed wholesale by the upcoming native-SQL
315+
channel-state migration. bbolt and etcd retain the synchronous one-shot
316+
close path, where nested-bucket deletion is already cheap.
317+
318+
> ⚠️ **Downgrade warning.** On sqlite/postgres, once a channel is
319+
> closed under this build the chanBucket and its nested state remain
320+
> on disk; the close is signalled only by the `outpointClosed` flip
321+
> in the outpoint index. Earlier `lnd` releases do not consult that
322+
> flip when iterating `openChannelBucket`, so downgrading to a
323+
> pre-0.21 binary after closing channels on these backends will
324+
> resurrect those channels as open in `listchannels`,
325+
> `pendingchannels`, and the chain-watch path. Operators who close
326+
> channels on sqlite/postgres after upgrading should treat the
327+
> upgrade as one-way for that database; bbolt and etcd users are unaffected
328+
> because the close path on those backends still deletes the chanBucket.
329+
299330
## Deprecations
300331

301332
### ⚠️ **Warning:** Deprecated fields in `lnrpc.Hop` will be removed in release version **0.22**

itest/lnd_wipe_fwdpkgs_test.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,28 @@ func testWipeForwardingPackages(ht *lntest.HarnessTest) {
8585
// close channel should now become pending force closed channel.
8686
pendingAB = ht.AssertChannelPendingForceClose(bob, chanPointAB).Channel
8787

88-
// Check the forwarding pacakges are deleted.
89-
require.Zero(ht, pendingAB.NumForwardingPackages)
90-
91-
// For Alice, the forwarding packages should have been wiped too.
92-
pending := ht.AssertChannelPendingForceClose(alice, chanPointAB)
93-
pendingAB = pending.Channel
94-
require.Zero(ht, pendingAB.NumForwardingPackages)
88+
// On backends that close channels via tombstone markers (sqlite,
89+
// postgres), the per-channel forwarding-package bucket is left on
90+
// disk by design — the synchronous close path's nested-bucket
91+
// delete is exactly what tombstoning avoids. The bytes are reclaimed
92+
// by the upcoming native-SQL channel-state migration. The unit-test
93+
// suite in channeldb covers the tombstone semantics directly, so
94+
// here we just skip the post-close fwd-pkg assertions on those
95+
// backends while still exercising the rest of the close flow for
96+
// backend symmetry.
97+
if !ht.UsesClosedChanTombstones() {
98+
require.Zero(ht, pendingAB.NumForwardingPackages)
99+
100+
// For Alice, the forwarding packages should have been wiped
101+
// too.
102+
pending := ht.AssertChannelPendingForceClose(alice, chanPointAB)
103+
pendingAB = pending.Channel
104+
require.Zero(ht, pendingAB.NumForwardingPackages)
105+
} else {
106+
// Still drive Alice's pending-force-close lookup so the rest
107+
// of the test stays backend-symmetric.
108+
ht.AssertChannelPendingForceClose(alice, chanPointAB)
109+
}
95110

96111
// Alice should one pending sweep.
97112
ht.AssertNumPendingSweeps(alice, 1)

lntest/harness.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,6 +1444,18 @@ func (h *HarnessTest) IsPostgresBackend() bool {
14441444
return h.manager.dbBackend == node.BackendPostgres
14451445
}
14461446

1447+
// UsesClosedChanTombstones reports whether the test harness's database
1448+
// backend closes channels via tombstone markers rather than cascading the
1449+
// nested-bucket delete. This is true on the KV-over-SQL backends (sqlite,
1450+
// postgres) and false on bbolt. Tests that observe forwarding-package or
1451+
// revocation-log deletion immediately after a channel close should consult
1452+
// this predicate; on tombstone backends the bulk state remains on disk
1453+
// until the upcoming native-SQL channel-state migration reclaims it.
1454+
func (h *HarnessTest) UsesClosedChanTombstones() bool {
1455+
return h.manager.dbBackend == node.BackendSqlite ||
1456+
h.manager.dbBackend == node.BackendPostgres
1457+
}
1458+
14471459
// fundCoins attempts to send amt satoshis from the internal mining node to the
14481460
// targeted lightning node. The confirmed boolean indicates whether the
14491461
// transaction that pays to the target should confirm. For neutrino backend,

0 commit comments

Comments
 (0)