') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Defer ChainMonitor updates and persistence to flush (wrapper approach) by joostjager · Pull Request #4345 · lightningdevkit/rust-lightning · GitHub
Skip to content

Defer ChainMonitor updates and persistence to flush (wrapper approach) - #4345

Closed
joostjager wants to merge 1 commit into
lightningdevkit:mainfrom
joostjager:chain-mon-deferred-writes
Closed

Defer ChainMonitor updates and persistence to flush (wrapper approach)#4345
joostjager wants to merge 1 commit into
lightningdevkit:mainfrom
joostjager:chain-mon-deferred-writes

Conversation

@joostjager

@joostjagerjoostjager commented Jan 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduce DeferredChainMonitor, a wrapper around ChainMonitor that queues watch_channel and update_channel operations, returning InProgress until flush() is called. This enables batched persistence of monitor updates after ChannelManager persistence, ensuring correct ordering where the ChannelManager state is never ahead of the monitor state on restart.

The Problem

There's a race condition that can cause channel force closures: if the node crashes after writing channel monitors but before writing the channel manager, the monitors will be ahead of the manager on restart. This can lead to state desync and force closures.

The Solution

By deferring monitor writes until after the channel manager is persisted (via flush()), we ensure the manager is always at least as up-to-date as the monitors.

Key changes:

  • DeferredChainMonitor queues monitor operations and returns InProgress
  • Calling flush() applies pending operations and persists monitors
  • All ChainMonitor traits (Listen, Confirm, EventsProvider, etc.) are passed through, allowing drop-in replacement
  • Background processor updated to capture pending count before ChannelManager persistence, then flush after persistence completes

Performance 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 QueuedKVStoreSync wrapper 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 MonitorUpdatingPersister to queue persist operations in memory, with actual writes happening on flush(). Adds flush() to the Persist trait and ChainMonitor.

Trade-offs: Only fixes the issue for MonitorUpdatingPersister; custom Persist implementations remain vulnerable to the race condition.

3. Queue internally in ChainMonitor (#4351)

Modifies ChainMonitor directly to queue operations internally, returning InProgress until flush() is called.

Trade-offs: Requires an enormous amount of test changes since existing tests expect immediate persistence behavior.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 Hi! I see this is a draft PR.
I'll wait to assign reviewers until you mark it as ready for review.
Just convert it out of draft status when you're ready for review!

@joostjagerjoostjager changed the title Chain mon deferred writesDefer ChainMonitor updates and persistence to flush()Jan 26, 2026
@joostjager

Copy link
Copy Markdown
ContributorAuthor

Added a DeferredChainMonitor wrapper instead of modifying ChainMonitor directly. The wrapper intercepts watch_channel and update_channel calls, queues them, and returns InProgress. When flush is called, it processes the queued operations and persists them in the correct order after ChannelManager persistence. This approach keeps ChainMonitor unchanged so that existing tests which expect synchronous behavior continue to work without modification. Only the background processor and production code paths use the deferred wrapper while the test suite can keep using ChainMonitor directly.

@joostjager
joostjagerforce-pushed the chain-mon-deferred-writes branch 3 times, most recently from 36a8b33 to 73c0a66CompareJanuary 26, 2026 13:59
@joostjager

Copy link
Copy Markdown
ContributorAuthor

Initially attempted to implement this as a thin adapter/wrapper that would sit between the ChannelManager and an existing ChainMonitor, forwarding calls while deferring the Watch operations. However, when integrating with ldk-node, this approach quickly ran into Rust ownership and lifetime issues since it required keeping both the original ChainMonitor and the wrapper around simultaneously. The current implementation takes a simpler approach where DeferredChainMonitor owns its own ChainMonitor internally and implements Deref to it, making it a complete drop-in replacement that can be instantiated with the same parameters as ChainMonitor while exposing all the same traits and methods.

@joostjager
joostjagerforce-pushed the chain-mon-deferred-writes branch 2 times, most recently from 5bd0ea3 to 0c005d0CompareJanuary 26, 2026 14:08
@codecov

codecovBot commented Jan 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.73780% with 87 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.01%. Comparing base (4800a47) to head (bc5bfac).
⚠️ Report is 48 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/chain/deferred.rs88.88%60 Missing and 7 partials ⚠️
lightning-background-processor/src/lib.rs68.75%11 Missing and 4 partials ⚠️
lightning/src/chain/chainmonitor.rs0.00%5 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4345 +/- ##
========================================
Coverage 86.01% 86.01% ========================================
Files 156 157 +1 Lines 102781 103751 +970 Branches 102781 103751 +970 ========================================
+ Hits 88409 89245 +836 - Misses 11864 11976 +112 - Partials 2508 2530 +22 
FlagCoverage Δ
tests86.01% <86.73%> (+<0.01%)⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

/// `update_channel` operations until `flush()` is called, using real
/// ChannelManagers and a complete channel open + payment flow.
#[test]
fn test_deferred_monitor_payment() {

@joostjagerjoostjagerJan 28, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to include a test that sets up from scratch, because the test infra is built around ChainMonitor. I am not sure if we should want something like this, or just let it be covered in ldk-node.

Err(()) => {
// watch_channel failed (e.g., duplicate channel).
// The monitor was consumed so we can't retry.
log_error!(logger, "watch_channel failed for channel {}", channel_id);

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error was previously reported back to chan mgr, but now not anymore.

@joostjager

Copy link
Copy Markdown
ContributorAuthor

Extended PR description with outcome of high-latency benchmark

@joostjager
joostjagerforce-pushed the chain-mon-deferred-writes branch 2 times, most recently from 1071b3c to eb17bdfCompareFebruary 3, 2026 10:49
@joostjager

Copy link
Copy Markdown
ContributorAuthor

Rebased on top of the AChainMonitor trait change, so that deferred writing is optional in the background processor.

@joostjager
joostjagerforce-pushed the chain-mon-deferred-writes branch 2 times, most recently from b137684 to 92ad34aCompareFebruary 9, 2026 10:31

/// Returns a reference to the inner [`ChainMonitor`].
pub fn inner(&self) -> &ChainMonitor<ChannelSigner, C, T, F, L, P, ES> {
&self.chain_monitor

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not happy about exposing this

type EntropySource = ES;

fn get_cm(&self) -> &ChainMonitor<ChannelSigner, C, T, F, L, P, ES> {
&self.chain_monitor

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not happy about exposing this either

@joostjager
joostjagerforce-pushed the chain-mon-deferred-writes branch from 92ad34a to 5f521abCompareFebruary 9, 2026 11:27
@joostjager

joostjager commented Feb 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Not overly happy with the wrapper approach. It reintroduces type complexity (seven generic parameters, manual forwarding of traits), and inner()/get_cm() still leak the unwrapped ChainMonitor so perhaps fewer guarantees operations actually go through the deferred path. Also had to add a pub(crate) trait in production code purely for test mockability.

On testing, we should decide whether this gets full coverage within LDK or is integration-tested at the ldk-node level. Right now it's halfway both, with manual flush() calls sprinkled in places.

It does make me reconsider #4351 (queuing internally in ChainMonitor) with a runtime flag. This might also make testing easier, because it's the same ChainMonitor.

Introduces DeferredChainMonitor, a wrapper around ChainMonitor that
queues watch_channel and update_channel operations instead of executing
them immediately. Operations are flushed via flush() or flush_with_target()
after the ChannelManager has been persisted, ensuring crash-safe ordering.
Key components:
- MonitorFlushTarget trait: abstracts the flush destination, enabling
mock-based unit testing without real ChainMonitor setup
- flush_with_target(): processes queued operations against any
MonitorFlushTarget, calling channel_monitor_updated on Completed status
- AChainMonitor, Confirm, Watch, EventsProvider, and MessageSendEventsProvider
implementations that delegate to the inner ChainMonitor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@joostjager
joostjagerforce-pushed the chain-mon-deferred-writes branch from 5f521ab to bc5bfacCompareFebruary 9, 2026 11:40

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not overly happy with the wrapper approach. It reintroduces type complexity (seven generic parameters, manual forwarding of traits), and inner()/get_cm() still leak the unwrapped ChainMonitor so perhaps fewer guarantees operations actually go through the deferred path. Also had to add a pub(crate) trait in production code purely for test mockability.
..
It does make me reconsider #4351 (queuing internally in ChainMonitor) with a runtime flag. This might also make testing easier, because it's the same ChainMonitor.

Yea, I don't disagree I think? I mean its fine but the fact that the underlying leaks allowing misuse is definitely not ideal at all.

On testing, we should decide whether this gets full coverage within LDK or is integration-tested at the ldk-node level. Right now it's halfway both, with manual flush() calls sprinkled in places.

Given from the ChannelManager's PoV its "just" async persist, I don't think we need to worry about every functional test using the new logic or anything like that, but we should definitely have some test coverage where possible.

.await?;

// Flush all pending monitor writes after final channel manager persistence.
let pending_monitor_writes = chain_monitor.pending_operation_count();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we should move this above the CM write. A common cause of FCs is actually buggy shutdown logic where the node is still running while the BP is being shut down, causing monitor writes after the CM is written in the exit write above.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #4351

@joostjagerjoostjager changed the title Defer ChainMonitor updates and persistence to flush()Defer ChainMonitor updates and persistence to flush (wrapper approach)Feb 9, 2026
@joostjager

joostjager commented Feb 9, 2026

Copy link
Copy Markdown
ContributorAuthor

Moving to #4351 (queuing internally in ChainMonitor with a runtime flag). The main motivations:

  1. The wrapper leaks the unwrapped ChainMonitor through inner()/get_cm(), so there's no compile-time guarantee that operations go through the deferred path. With internal queuing, there's only one ChainMonitor and no way to bypass it.

  2. The wrapper requires seven generic parameters, manual trait forwarding for Listen/Confirm/EventsProvider/etc, and a pub(crate) trait added purely for test mockability. Internal queuing avoids all of that since it's the same ChainMonitor type regardless of mode.

  3. Being inside ChainMonitor allows better coordination with internal locking and early validation. For example, watch_channel in deferred mode can check that the channel doesn't already exist in monitors or the pending queue, erroring immediately rather than deferring the failure to flush time.

  4. Testing becomes simpler. Since it's the same ChainMonitor, existing test infrastructure works directly with a deferred flag. No need for a separate wrapper type in test utilities.

@joostjager

Copy link
Copy Markdown
ContributorAuthor

Closing in favor of #4351

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants

@joostjager@ldk-reviews-bot@TheBlueMatt