Defer ChainMonitor updates and persistence to flush()#4351
Defer ChainMonitor updates and persistence to flush()#4351joostjager wants to merge 5 commits intolightningdevkit:mainfrom
Conversation
|
👋 Thanks for assigning @TheBlueMatt as a reviewer! |
|
Closing this PR as #4345 seems to be the easiest way to go |
1f5cef4 to
30d05ca
Compare
|
The single commit was split into three: extracting internal methods, adding a deferred toggle, and implementing the deferral and flushing logic. flush() now delegates to the extracted internal methods rather than reimplementing persist/insert logic inline. Deferred mode is opt-in via a deferred bool rather than always-on. Test infrastructure was expanded with deferred-mode helpers and dedicated unit tests. |
2815bf9 to
3eb5644
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4351 +/- ##
==========================================
+ Coverage 85.87% 85.97% +0.09%
==========================================
Files 157 159 +2
Lines 103769 104868 +1099
Branches 103769 104868 +1099
==========================================
+ Hits 89115 90162 +1047
- Misses 12158 12198 +40
- Partials 2496 2508 +12
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
f964466 to
b140bf9
Compare
|
This PR is now ready for review. LDK-node counterpart: lightningdevkit/ldk-node#782 |
|
I think there was still a small race remaining. Pushed fix. |
|
🔔 1st Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
TheBlueMatt
left a comment
There was a problem hiding this comment.
feel free to squash
7ce7cf9 to
824e976
Compare
|
✅ Added second reviewer: @valentinewallace |
824e976 to
47070bd
Compare
|
Rebased without changes |
| match status { | ||
| ChannelMonitorUpdateStatus::Completed => { | ||
| let logger = WithContext::from(logger, None, Some(channel_id), None); | ||
| if let Err(e) = self.channel_monitor_updated(channel_id, update_id) { |
There was a problem hiding this comment.
Relatedly, the Persist docs state that channel_monitor_updated should be called once a full channelmonitor has been persisted, which I think is inaccurate (and later docs seem to contradict that).
There was a problem hiding this comment.
Updated the doc to clarify this. Each pending update ID must be individually marked complete via channel_monitor_updated, since the implementation uses retain to remove only the exact matching ID.
| }; | ||
|
|
||
| match status { | ||
| ChannelMonitorUpdateStatus::Completed => { |
There was a problem hiding this comment.
I seem to recall we started disallowing flipping between returning Completed and InProgress at some point. I'm not sure if that's still correct, but it may be worth looking into.
There was a problem hiding this comment.
The flipping concern doesn't apply here. The InProgress is returned by the deferred layer in ChainMonitor, not by the Persist implementation itself. The Persist impl can consistently return Completed; the deferred layer just wraps it with an initial InProgress to delay processing until after the ChannelManager is persisted. When flush() runs and the persister returns Completed, we call channel_monitor_updated to resolve the InProgress that the deferred layer returned earlier.
1ecd046 to
c6b30c9
Compare
|
Review comments addressed: https://github.com/lightningdevkit/rust-lightning/compare/47070bd..c6b30c93e31d462e628070267ec02f20b72452f2 |
lightning/src/util/test_utils.rs
Outdated
| use crate::chain::WatchedOutput; | ||
| #[cfg(any(test, feature = "_externalize_tests"))] | ||
| use crate::ln::chan_utils::CommitmentTransaction; | ||
| #[cfg(test)] |
There was a problem hiding this comment.
I think we can get rid of all these cfg(test) flags?
There was a problem hiding this comment.
I gated them because HolderCommitmentTransaction::dummy (used in dummy_monitor) is also cfg(test). But now changed that one too, so that these flags can be dropped.
valentinewallace
left a comment
There was a problem hiding this comment.
Good to land on my end and save minor feedback for follow-up
|
I guess we should rename #4286 now. This fix is pretty ridiculously simple given the safety it adds. Have we talked to any users about adopting this? |
The previous wording implied that persisting a full ChannelMonitor would automatically resolve all pending updates. Reword to make clear that each update ID still needs to be individually marked complete via channel_monitor_updated, even after a full monitor persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract the ChannelMonitor construction boilerplate from channelmonitor test functions into a reusable dummy_monitor helper in test_utils.rs, generic over the signer type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pure refactor: move the bodies of Watch::watch_channel and Watch::update_channel into methods on ChainMonitor, and have the Watch trait methods delegate to them. This prepares for adding deferred mode where the Watch methods will conditionally queue operations instead of executing them immediately. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a `deferred` parameter to `ChainMonitor::new` and `ChainMonitor::new_async_beta`. When set to true, the Watch trait methods (watch_channel and update_channel) will unimplemented!() for now. All existing callers pass false to preserve current behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the unimplemented!() stubs with a full deferred write implementation. When ChainMonitor has deferred=true, Watch trait operations queue PendingMonitorOp entries instead of executing immediately. A new flush() method drains the queue and forwards operations to the internal watch/update methods, calling channel_monitor_updated on Completed status. The BackgroundProcessor is updated to capture pending_operation_count before persisting the ChannelManager, then flush that many writes afterward - ensuring monitor writes happen in the correct order relative to manager persistence. Key changes: - Add PendingMonitorOp enum and pending_ops queue to ChainMonitor - Implement flush() and pending_operation_count() public methods - Integrate flush calls in BackgroundProcessor (both sync and async) - Add TestChainMonitor::new_deferred, flush helpers, and auto-flush in release_pending_monitor_events for test compatibility - Add create_node_cfgs_deferred for deferred-mode test networks - Add unit tests for queue/flush mechanics and full payment flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
c6b30c9 to
43bfb1b
Compare
Yes, we could reframe #4286 as a performance optimization now.
The plan is to have deferred writes always-on in ldk-node. In the tests that I did, there is no measurable performance downside currently because LDK isn't optimized enough to notice the difference. So from the ldk-node user perspective, the message is simply "force-closures caused by inconsistent persistence are fixed". |
| /// | ||
| /// The `wrap_signer` closure converts the raw `InMemorySigner` into the desired signer type | ||
| /// (e.g. wrapping it in `TestChannelSigner` or passing it through unchanged). | ||
| pub fn dummy_monitor<S: sign::ecdsa::EcdsaChannelSigner + 'static>( |
There was a problem hiding this comment.
nit: I don't love these kinds of generic "dummy builders" in test_utils.rs. They're pretty narrowly useful for only testing a fake ChannelMonitor in some setups because they may or may not be in a valid state at all. Can we leave this method in channelmontor.rs (or chain)?
There was a problem hiding this comment.
I can move it, but then it needs to live outside channelmonitor's test mod. Otherwise the chainmonitor tests can't access it I believe?
| /// | ||
| /// Note that async monitor updating is considered beta, and bugs may be triggered by its use. | ||
| /// | ||
| /// When `deferred` is `true`, [`chain::Watch::watch_channel`] and |
There was a problem hiding this comment.
nit Looks like the doc additions were squashed into the wrong commit.
There was a problem hiding this comment.
It is a product of the somewhat artificial commit split. The commit that adds deferred was intended to purely mechanically get those changes out of the way, and clearly show that deferred mode isn't enabled anywhere yet.
Giving it an implementation and explaining what it does seemed to belong in the same commit.
| /// A new monitor to insert and persist. | ||
| NewMonitor { channel_id: ChannelId, monitor: ChannelMonitor<ChannelSigner> }, | ||
| /// An update to apply and persist. | ||
| Update { channel_id: ChannelId, update: ChannelMonitorUpdate }, |
There was a problem hiding this comment.
Doesn't this introduce the bug you described in #4431 even in the runtime case? In this world, we don't apply an update in-line, and thus can have a pending update when a channel closes. We need to test the crash case for that but also need to look into the async-application case here.
There was a problem hiding this comment.
Why would this introduce that bug? The conclusion of #4431 was that it resolves once the close confirms. In the current PR, the async persistence concept remains unchanged, so I'd think it cannot introduce a bug?
There was a problem hiding this comment.
There wasn't a crash case btw. Just that the payment remained pending for what I initially thought would be indefinitely, but turned out to be until confirmation.
Summary
Modify
ChainMonitorinternally to queuewatch_channelandupdate_channeloperations, returningInProgressuntilflush()is called. This enables persistence of monitor updates afterChannelManagerpersistence, ensuring correct ordering where theChannelManagerstate is never ahead of the monitor state on restart. The new behavior is opt-in via adeferredswitch.Key changes:
ChainMonitorgains adeferredswitch to enable the new queuing behaviorInProgressflush()applies pending operations and persists monitorsChannelManagerpersistence, then flush after persistence completesPerformance Impact
Multi-channel, multi-node load testing (using ldk-server chaos branch) shows no measurable throughput difference between deferred and direct persistence modes.
This is likely because forwarding and payment processing are already effectively single-threaded: the background processor batches all forwards for the entire node in a single pass, so the deferral overhead doesn't add any meaningful bottleneck to an already serialized path.
For high-latency storage (e.g., remote databases), there is also currently no significant impact because channel manager persistence already blocks event handling in the background processor loop (test). If the loop were parallelized to process events concurrently with persistence, deferred writing would become comparatively slower since it moves the channel manager round trip into the critical path. However, deferred writing would also benefit from loop parallelization, and could be further optimized by batching the monitor and manager writes into a single round trip.
Alternative Designs Considered
Several approaches were explored to solve the monitor/manager persistence ordering problem:
1. Queue at KVStore level (#4310)
Introduces a
QueuedKVStoreSyncwrapper that queues all writes in memory, committing them in a single batch at chokepoints where data leaves the system (get_and_clear_pending_msg_events,get_and_clear_pending_events). This approach aims for true atomic multi-key writes but requires KVStore backends that support transactions (e.g., SQLite); filesystem backends cannot achieve full atomicity.Trade-offs: Most general solution but requires changes to persistence boundaries and cannot fully close the desync gap with filesystem storage.
2. Queue at Persister level (#4317)
Updates
MonitorUpdatingPersisterto queue persist operations in memory, with actual writes happening onflush(). Addsflush()to thePersisttrait andChainMonitor.Trade-offs: Only fixes the issue for
MonitorUpdatingPersister; customPersistimplementations remain vulnerable to the race condition.3. Queue at ChainMonitor wrapper level (#4345)
Introduces
DeferredChainMonitor, a wrapper aroundChainMonitorthat implements the queue in a separate wrapper layer. AllChainMonitortraits (Listen,Confirm,EventsProvider, etc.) are passed through, allowing drop-in replacement.Trade-offs: Requires re-implementing all trait pass-throughs on the wrapper. Keeps the core
ChainMonitorunchanged but adds an external layer of indirection.