Skip to content

Fully switch to async KVStore persistence#919

Open
tnull wants to merge 21 commits into
lightningdevkit:mainfrom
tnull:2026-06-async-kvstore-persistence
Open

Fully switch to async KVStore persistence#919
tnull wants to merge 21 commits into
lightningdevkit:mainfrom
tnull:2026-06-async-kvstore-persistence

Conversation

@tnull
Copy link
Copy Markdown
Collaborator

@tnull tnull commented Jun 3, 2026

We planned to make the full switch to the async KVStore for a while. Here we do just that: first step-by-step moving the remaining dependencies over to use KVStore, then finally dropping KVStoreSync and all implementations.

Depends on lightningdevkit/rust-lightning#4658 (will drop the DROPME commit once that's merged).

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Jun 3, 2026

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@tnull tnull marked this pull request as draft June 3, 2026 11:00
@tnull tnull force-pushed the 2026-06-async-kvstore-persistence branch 2 times, most recently from 42c16e2 to 4371f6d Compare June 3, 2026 11:25
@tnull tnull mentioned this pull request Jun 3, 2026
7 tasks
@tnull tnull force-pushed the 2026-06-async-kvstore-persistence branch 2 times, most recently from 05ebc28 to 911c169 Compare June 3, 2026 12:15
@tnull tnull added this to the 0.8 milestone Jun 3, 2026
@tnull tnull force-pushed the 2026-06-async-kvstore-persistence branch 5 times, most recently from 1b4d24f to c62a503 Compare June 3, 2026 12:28
@tnull tnull marked this pull request as ready for review June 3, 2026 12:28
@tnull tnull requested a review from joostjager June 3, 2026 12:28
Copy link
Copy Markdown
Contributor

@joostjager joostjager left a comment

Choose a reason for hiding this comment

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

Overall happy with this simplification. Big changeset though, even though not all that much is happening. Risk nonetheless.

Bigger open question for me is what the latest is on runtime abstraction and how that is going to work in combination with potential wasm support.

Also, are there sync public api calls that can now be made async such as export_pathfinding_scores?

Comment thread src/payment/asynchronous/static_invoice_store.rs Outdated
Comment thread src/peer_store.rs
locked_peers.insert(peer_info.node_id, peer_info);
PeerStoreSerWrapper(&locked_peers).encode()
};
self.persist_peers(data).await
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it safe to have already dropped the lock? If two add_peer calls run interleaved, outdated state may be written?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fair, would you prefer we take a mutation guard as we do in DataStore in instances like this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think so? Not sure if there are other ways to do it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, now did add safeguards for node metrics and peer store persistence, too, but once we switch to be fully async we'll be able to switch to tokio::sync::{Mutex,RwLock}s and hold them across await.

Comment thread src/event.rs
Comment thread src/data_store.rs
Comment thread src/data_store.rs
let _guard = self.mutation_lock.lock().await;

self.persist(&object)?;
self.persist(&object).await?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Now that this no longer holds self.objects, doesn't that, isn't this creating a window where readers get interleaved in an undesirable way?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

That's why we introduced the mutation_lock for the write paths. Or maybe I'm misunderstanding the question?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes it works for the writers, but readers can still see old in-memory state while a write is being persisted. insert_or_update is the other way around. A reader can see in-memory state that isn't on disk yet. Perhaps it is acceptable, but a comment is justified then.

Comment thread src/io/vss_store.rs Outdated
Comment thread src/io/test_utils.rs
signer_provider: &'a test_utils::TestKeysInterface,
}

impl<K: KVStore + Sync> TestMonitorUpdatePersister<'_, K> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Commit message says dropping the trait, but this looks more substantial?

Comment thread src/io/test_utils.rs
pub(crate) fn create_persister<'a, K: KVStoreSync + Sync>(
store: &'a K, chanmon_cfg: &'a TestChanMonCfg, max_pending_updates: u64,
pub(crate) fn create_persister<'a, K: KVStore + Sync>(
store: &'a K, chanmon_cfg: &'a TestChanMonCfg, _max_pending_updates: u64,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wasn't max pending updates exercised in a test?

Comment thread src/io/vss_store.rs
Comment thread src/io/vss_store.rs Outdated
)?;

let store_key = self.build_obfuscated_key(&primary_namespace, &secondary_namespace, &key);
let schema_version = match self.schema_version().await {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This repetitive code doesn't look great. Maybe an explicit setup call is better?

@tnull tnull force-pushed the 2026-06-async-kvstore-persistence branch 2 times, most recently from 290b0a5 to 9ad8f2d Compare June 3, 2026 15:42
Copy link
Copy Markdown
Contributor

@benthecarman benthecarman left a comment

Choose a reason for hiding this comment

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

the vss ci job hung for 2 hours hopefully a transient issue

Comment thread src/peer_store.rs
Comment thread src/io/utils.rs
@benthecarman
Copy link
Copy Markdown
Contributor

codex seems to agree on the locks

  • [P2] Serialize peer-store persistence updates — /home/ben/projects/ldk-node/src/peer_store.rs:43-52
    When two peer-store mutations overlap, this now releases the peer map lock before the async write completes, so an
    older snapshot can persist after a newer one. For example, concurrent add_peer calls can leave memory with both
    peers but the stored peer list with only the first one, causing reconnect peers to be missing after restart. Please
    serialize the mutation plus persistence, as DataStore now does with a separate async mutation lock.

  • [P2] Preserve node-metrics update ordering — /home/ben/projects/ldk-node/src/io/utils.rs:346-351
    If two node-metrics updates run concurrently, the lock is released before the async KV write, so an earlier encoded
    snapshot can finish after a later one and overwrite it. This can drop one of the timestamp fields on disk and make
    the node believe a sync/broadcast task has not run after restart. The previous code explicitly held the write lock
    through persistence to avoid this, so this path needs equivalent serialization for the async write.

@tnull tnull force-pushed the 2026-06-async-kvstore-persistence branch from 9ad8f2d to 8b93895 Compare June 5, 2026 08:04
@tnull
Copy link
Copy Markdown
Collaborator Author

tnull commented Jun 5, 2026

As discussed offline, now updated:

  1. Re-added the internal runtimes for Postgres / VSS to avoid tokio getting stuck if we end up using block_on while the same thread holds the IO driver. We'll be able to revert this/drop the runtimes once we go fully async API in a following PR.
  2. Added mutation locks for node metrics and peer store persistence for now, but we'll be able to simplify more once we switch to tokio::sync, once we go fully-async.
  3. Added a commit that enables eager handoff of the IO driver if we're built under cfg(tokio_unstable) and enables that for bindings builds.

@tnull tnull requested review from benthecarman and joostjager June 5, 2026 08:25
tnull added 5 commits June 5, 2026 10:35
Use a temporary rust-lightning fork revision that exposes async
migratable KV-store support. This lets ldk-node migrate filesystem
stores without reimplementing LDK's migration logic locally.

Co-Authored-By: HAL 9000
Read and write BDK wallet state through async KVStore helpers while
keeping the current WalletPersister entry points bridged through the
node runtime. This reduces the wallet persistence surface that still
depends on KVStoreSync.

Co-Authored-By: HAL 9000
Static invoice persistence already runs from async handlers, so use
KVStore directly instead of routing those reads and writes through
the blocking KVStoreSync trait.

Co-Authored-By: HAL 9000
Persist peer store updates through async KVStore operations. The
synchronous node APIs keep bridging at their runtime boundary while
async event handling awaits peer persistence directly.

Co-Authored-By: HAL 9000
tnull added 16 commits June 5, 2026 10:35
Persist DataStore mutations through async KVStore operations while
keeping the existing synchronous APIs bridged through the node
runtime. Async event handling now awaits payment store writes
directly.

Co-Authored-By: HAL 9000
Persist node metric updates through async KVStore writes and await
them from the chain, gossip, and scoring tasks. This removes the
remaining blocking metrics writer while keeping the helper name
stable.

Co-Authored-By: HAL 9000
Avoid introducing a temporary macro when moving node metrics persistence onto async KV storage.

Co-Authored-By: HAL 9000
Persist the on-chain wallet through BDK's AsyncWalletPersister so
wallet state writes use the async KVStore path. Existing synchronous
wallet APIs keep bridging through the node runtime until their
callers are made async.

Co-Authored-By: HAL 9000
Open filesystem stores through the async LDK migration helper so
v1-to-v2 store migration no longer depends on the blocking KVStoreSync
migration path.

Co-Authored-By: HAL 9000
Move the existing in-memory test store into a shared module without
changing its behavior. This lets integration tests reuse it while
keeping the later async TestSyncStore change separate.

Co-Authored-By: HAL 9000
Keep the shared test store move as a pure code move by restoring the original comments and spacing.

Co-Authored-By: HAL 9000
Exercise async KVStore operations in TestSyncStore and filesystem
migration tests while keeping the temporary sync comparison path until
the final KVStoreSync removal. Also route pathfinding score export
through async KVStore reads.

Co-Authored-By: HAL 9000
Drop the remaining synchronous KV store trait bounds and
implementations. After the preceding migrations, custom stores only
need to provide async KVStore persistence.

Co-Authored-By: HAL 9000
Compile the VSS persistence tests after the shared KV store helper moved to async persistence.

Co-Authored-By: HAL 9000
Add a crate-local runtime wrapper for store backends that need to keep their I/O isolated while shutting down safely from async contexts.

Co-Authored-By: HAL 9000
Enable Tokio's eager driver handoff when building with tokio_unstable so node-owned runtimes can use the dedicated driver handoff path where available.

Build binding artifacts and selected CI coverage with tokio_unstable so the cfg-gated runtime path remains exercised.

Co-Authored-By: HAL 9000
@tnull tnull force-pushed the 2026-06-async-kvstore-persistence branch from 326cbf7 to be2b74d Compare June 5, 2026 08:35
@tnull
Copy link
Copy Markdown
Collaborator Author

tnull commented Jun 5, 2026

Hmm, seems Github CI might have an outage, at least it's not running right now..

@tnull tnull removed the request for review from joostjager June 5, 2026 08:55
Comment thread src/runtime.rs
let mut runtime_builder = tokio::runtime::Builder::new_multi_thread();
runtime_builder.enable_all();
#[cfg(tokio_unstable)]
runtime_builder.enable_eager_driver_handoff();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd definitely add docs why this is needed and/or link to the tokio issue.

Comment thread src/peer_store.rs
}

pub(crate) async fn add_peer(&self, peer_info: PeerInfo) -> Result<(), Error> {
let _guard = self.mutation_lock.lock().await;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you think that reading doesn't need to be in this lock too? Similar to what we discussed in the other store discussion threads? #919 (comment)

Comment thread src/runtime.rs
Handle(tokio::runtime::Handle),
}

pub(crate) struct StoreRuntime {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can also use some serious documentation on why it is needed, even if temporary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also wondering if it isn't possible to unify this with the existing Runtime wrapper? That might also make the various modes more clear: current runtime, passed runtime, new runtime, or some combo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Also wondering if it isn't possible to unify this with the existing Runtime wrapper? That might also make the various modes more clear: current runtime, passed runtime, new runtime, or some combo.

Considered that, but given the plan is to remove it ASAP, I decided to keep it separate which will make the cleanup much more straightforward.

Comment thread src/runtime.rs
format!("{}-{}", thread_name_prefix, id)
})
.worker_threads(worker_threads)
.max_blocking_threads(worker_threads)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is the reason for this setting?

Comment thread src/runtime.rs
) -> io::Result<Self> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name_fn(move || {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe add this debugging/naming setting also to other places where a runtime is created?

Comment thread src/io/vss_store.rs
tokio::task::block_in_place(move || drop(internal_runtime));
if let Some(runtime) = self.internal_runtime.take() {
if let Ok(runtime) = Arc::try_unwrap(runtime) {
runtime.shutdown_background();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What does it mean if we skip shutdown here?

// Keep this small while still allowing progress if one runtime worker blocks on sync store access.
const INTERNAL_RUNTIME_WORKERS: usize = 2;

async fn run_on_internal_runtime<T>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can this be a method on StoreRuntime?

Comment thread src/runtime.rs
runtime: Option<tokio::runtime::Runtime>,
}

impl StoreRuntime {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Name describes usage, not really what it does. IsolatedRuntime?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants