RGB KVStore Integration - #17
Conversation
11b570e to
07f18c9
Compare
07f18c9 to
b061470
Compare
|
@dcorral could you please rebase this PR? |
b061470 to
16bf7fc
Compare
|
@zoedberg done! |
zoedberg
left a comment
There was a problem hiding this comment.
Thanks! I left some change requests. Also, instead of serializing structs to JSON strings and then convert the strings to bytes I would use bincode to serialize the structs directly in bytes.
5d04288 to
8024e97
Compare
| pub(crate) ldk_data_dir: PathBuf, | ||
|
|
||
| /// KVStore for RGB data persistence | ||
| pub(crate) rgb_kv_store: Arc<dyn KVStoreSync + Send + Sync>, |
There was a problem hiding this comment.
Why did you choose to use KVStoreSync instead of KVStore (its async version)? I would use the latter if possible, since RLN runs in an async environment
There was a problem hiding this comment.
I used KVStoreSync because all the call sites are in synchronous code paths: color_commitment, color_htlc, color_closing, update_rgb_channel_id, handle_funding, etc. are all sync functions inside channel.rs and channelmanager.rs, which are entirely sync.
Using the async KVStore would require making all of these async, which would mean changing a big chunk of LDK's internals. do you think it's worth doing that, or is KVStoreSync ok for now?
There was a problem hiding this comment.
What about doing like ldk-node does? Which is implementing an inner store and then implementing for the wrapper store both KVStore and KVStoreSync. That way we could use the KVStoreSync in LDK (where we don't have an async environment) and KVStore in RLN (where we do have an async environment)
There was a problem hiding this comment.
Alright, sounds like a plan.
There was a problem hiding this comment.
Looking into it, KVStoreSyncWrapper already exists and it wraps any KVStoreSync into KVStore by boxing the sync result into a future. So the rust-lightning side can stay as-is with KVStoreSync and on the RLN side we just wrap the sea-orm store with KVStoreSyncWrapper to expose the async KVStore trait where needed.
There was a problem hiding this comment.
All three done:
- generic KV: KVStoreSync + Send + Sync + 'static in e67779b, only where the Arc fields actually lived; no dyn KVStoreSync left
- SeaOrmKvStore implements both KVStore (native async) and KVStoreSync
- RlnDatabase dropped
One deviation though, the KVStoreSync impl doesn't use block_in_place + Handle::current().block_on(), that deadlocks. The sync calls run on the node's runtime, so when all workers are parked waiting on DB results, nobody is left to poll the DB futures they're waiting on. Verified with a 30s timeout on the the sync→async adapter in kv_store.rs: the queued DB tasks only ran once the timeout unwound the blocked caller. Instead block_on spawns the future on a small dedicated runtime and waits on a channel inside block_in_place. Unlike the old DB_RUNTIME: multi-threaded, no per-call thread spawn. Async path unaffected.
There was a problem hiding this comment.
Verified all three on the current head, looks good, thank you.
On the deviation:
the KVStoreSync impl doesn't use block_in_place + Handle::current().block_on(), that deadlocks. The sync calls run on the node's runtime, so when all workers are parked waiting on DB results, nobody is left to poll the DB futures they're waiting on
From my understanding this isn't how block_in_place behaves: it hands the worker's core off to another thread precisely so blocked callers can't starve the runtime, and Handle::block_on inside it polls the future on the calling thread. What would break is the nested-executor case: the coloring paths call the KVStore from inside futures::executor::block_on (see _get_rgb_wallet), and Handle::current().block_on() from within that nested context is not allowed. Can you confirm whether your 30s-timeout repro was on one of those paths? If that's the case, then the current design is the right call, just update the comment on top of fn db_runtime() to mention the nested futures::executor::block_on as the reason. If the deadlock reproduced on a plain sync path with a correctly-placed block_in_place, I'd like to see it, because then something else is off.
There was a problem hiding this comment.
Confirmed. The deadlock was on a nested path: the thread dump shows a peer-handler task inside futures::executor::block_on(_get_rgb_wallet) calling the store, so Handle::current().block_on() was illegal there, as you said. I don't have a deadlock to show on a plain sync path with a correctly-placed block_in_place.
On keeping the dedicated runtime: the sync store calls execute while holding LDK locks on runtime workers, e.g. internal_commitment_signed takes peer_state_mutex.lock() and the commitment path calls color_commitment, which hits the store. Waiting on the caller's runtime from inside those critical sections ties lock hold times to that runtime's scheduling; the dedicated runtime makes DB completion independent of it.
Updated the fn db_runtime() comment to mention the nested futures::executor::block_on as the reason.
There was a problem hiding this comment.
Thanks for confirming the nested-executor cause.
On the lock-hold-time argument: I don't think it holds up as a reason to keep the dedicated runtime. The caller thread blocks for the full DB round-trip while holding peer_state either way, the dedicated runtime doesn't shorten the critical section, it only decouples the DB future's wakeups from the main runtime's I/O driver. With a correctly-placed block_in_place the runtime backfills the worker, so that dependency only matters under severe saturation. Meanwhile the dedicated runtime costs a cross-runtime spawn plus a channel wait on every sync DB call.
So the only hard blocker for block_in_place + Handle::current().block_on() is the nested futures::executor::block_on context, and I'd rather remove that than design around it. Looking at why it exists: _get_rgb_wallet and _accept_transfer are async only to spawn_blocking the wallet construction, but every caller is sync and immediately does futures::executor::block_on(...). That parks the calling thread for the full duration anyway, so the spawn_blocking buys nothing, it just adds a thread handoff and creates the illegal nested context.
For these reasons, I think we should:
- make
_get_rgb_wallet/_accept_transferplain sync functions, wrapping the blocking wallet work intokio::task::block_in_placeinstead ofspawn_blocking - remove all
futures::executor::block_oncall sites in the coloring paths (and the sameblock_on(spawn_blocking(...))pattern in RLN's output sweeper), so the invariant becomes "no nested executors anywhere" - then switch the
KVStoreSyncimpl toblock_in_place+Handle::current().block_on(), documenting the "no nested executors" invariant assumption, and dropdb_runtime()entirely
As a follow-up after this PR lands: the deeper fix is to stop constructing a fresh rgb-lib wallet on every coloring call and instead inject RLN's long-lived Arc<Mutex<RgbLibWallet>> through the same plumbing this PR built for rgb_kv_store (possibly bundled with the kv_store and the consignment dir into a single injected context struct). That removes wallet construction and go_online from the critical sections entirely and makes the coloring paths plain sync method calls. We'll tackle that in a dedicated PR after this one, so no need to address it here.
There was a problem hiding this comment.
I implemented all three points. 1 and 2 are done and kept: _get_rgb_wallet/_accept_transfer are plain sync fns using block_in_place, and no futures::executor::block_on call sites remain anywhere (coloring paths and RLN's output spender included).
Point 3 deadlocks.
With the store on block_in_place + Handle::block_on and db_runtime() dropped, swap_roundtrip_sell hangs reproducibly. I traced it to sqlx itself: when a PoolConnection is dropped, sqlx returns it to the pool by spawning a task on the current runtime. So driving the future on the caller's thread makes the connection-return land on the caller's runtime. Once that runtime is saturated (which LDK sync code can cause on its own by blocking on std mutexes on workers without block_in_place), returns stop running, the pool drains, and the next acquire waits forever. A minimal unit test shows it deterministically: a sync writer holding a std mutex while another task pins the only worker completes write 0 and hangs on write 1, waiting for a connection whose return task is queued behind the pinned worker. This isn't fixable with better placement of block_in_place sqlx structurally needs a live executor to give connections back.
One idea to actually remove the dedicated runtime would be taking sqlx out of the store's data path: do the kv operations with rusqlite (plain blocking SQLite) under block_in_place, with the async KVStore impl delegating via spawn_blocking. There'd be no pool, no spawned return tasks, no timers — nothing that can be starved, on either path. sea-orm could stay for migrations, the entities and the typed config/auth tables, with only the hot kv path going direct. Not sure it's the right trade-off since it walks part of the sea-orm integration back, so I'd like your take before going that way. If it sounds reasonable I'd draft it in a separate commit
| let rgb_info = kv_store.read_rgb_channel_info(&temp_chan_id, false).expect("rename ok"); | ||
| kv_store.write_rgb_channel_info(&chan_id, &rgb_info, false); | ||
| kv_store.remove_rgb_channel_info(&temp_chan_id, false).expect("rename ok"); |
There was a problem hiding this comment.
Having now the DB I think we can just make this an update, am I missing something?
There was a problem hiding this comment.
the KVStoreSync trait doesn't have a rename/update-key method though, so we'd need to either add one to the trait or handle it at the DB level in the RLN KVStore implementation. What's your suggestion?
There was a problem hiding this comment.
Right. Then let's keep the read, write and remove, but let's put them inside an update_rgb_channel_info method. Also, have you considered a way to avoid write concurrency? It would be nice to be able to use DB transactions but I'm not sure that's easy to achieve
There was a problem hiding this comment.
to get real atomicity we'd need to add a transaction() method to KVStoreSync that runs all operations within a single DB transaction. This requires refactoring SeaOrmKvStore to be generic over sea-orm's ConnectionTrait (since DatabaseTransaction and DatabaseConnection both implement it but are different types) and the fs based FilesystemStore in lightning-persister would also need to implement it somehow, which is non-trivial.
The main risk here is crash recovery rather than write concurrency since LDK already holds exclusive access per channel during these calls and this rename only happens once per channel at funding time.
There was a problem hiding this comment.
I'm not sure what do you intend here. Why do you say "requires refactoring SeaOrmKvStore to be generic over sea-orm's ConnectionTrait"? I don't see this requirement but maybe I'm over-simplifying this.
About write concurrency, my concern is broader, I was not talking about this method in particular.
There was a problem hiding this comment.
Thanks for coming back to this.
Concurrency
I don't see the per-key lock map / version counter in the current heads, did you forget to push?
Atomicity
since RgbKvStoreExt is new in this PR, I can add a provided method that implementations may override with a real transaction
Since RgbKvStoreExt is a blanket impl over all K: KVStoreSync, no concrete type can override it, as you noted in April. Instead KVStoreSync is implemented concretely per store, so a default method there can be overridden. I think you could do:
pub enum KvOp {
Write { secondary_namespace: String, key: String, value: Vec<u8> },
Remove { secondary_namespace: String, key: String },
}
// on KVStoreSync, with a provided default body:
fn execute_batch(&self, primary_namespace: &str, ops: Vec<KvOp>) -> Result<(), io::Error> {
// default: apply ops sequentially (today's behavior)
}SeaOrmKvStore overrides it with connection.begin(), FilesystemStore keeps the default, and RgbKvStoreExt keeps its blanket impl. update_rgb_channel_id / update_rgb_channel_info become a single execute_batch call. Also note there are more multi-step sequences than the two you listed (see color_commitment for example), so with one primitive they can all migrate.
Implementation note: make sure the batch composes with the per-key lock map: acquire the per-key locks in sorted key order (or all upfront) before starting the transaction, to avoid deadlocks between concurrent batches.
rgb_payment fix
Nice catch! Since it was a pre-existing bug it would be great if you could split it into its own PR. And if it's not too difficult please also add an anti-regression test that shows the bug before the fix.
There was a problem hiding this comment.
Concurrency: right, that hadn't been pushed. It's on the current heads now: per-key lock map plus a version counter assigned at call time, stale writes skipped ( RGB-Tools/rgb-lightning-node@6f06e82, with deterministic tests for the write/remove ordering).
Atomicity: implemented now KvOp + execute_batch as a provided method on KVStoreSync with a sequential default, SeaOrmKvStore overriding it with connection.begin(). Migrated the multi-step sequences: both pairs in color_commitment (creation and pending→proxy promotion), write_rgb_payment_info, update_rgb_channel_info / update_rgb_channel_id (single 4-op batch), handle_funding, and the openchannel pair on the RLN side.
On composition: the batch takes the per-key locks for all touched keys upfront in sorted order and uses a single version, so concurrent batches and single writes can't deadlock.
One gap worth deciding on: read-modify-write sequences (e.g. update_rgb_channel_amount: read info -> adjust amounts -> write back) are still not atomic, write ordering can't help when the value was computed from a stale read. Today that's safe because all callers are already serialized (LDK's under peer_state, RLN's in the event handler, which processes events one at a time), same as with the filesystem. If we want it closed at the store level, I'd add an update method to KVStoreSync following the same pattern as execute_batch: provided default doing read -> closure -> write (today's behavior), SeaOrmKvStore overriding it to hold the per-key lock across the whole sequence.
rgb_payment fix: will do, splitting it into its own PR against rgb branch. On the anti-regression test, since we are not compiling LDK's tests, should I create an RLN test restarting a node with an HTLC in flight?
There was a problem hiding this comment.
Verified both commits, thank you. The sorted upfront lock acquisition in execute_batch and the version handling on singles look correct, and the deadlock analysis holds (singles never hold one key lock while waiting on another, batches acquire in sorted order).
One semantic issue in the batch override though: the stale-write check is applied per-op (if *guards[idx] > version { continue; }), so if one key of a batch was concurrently written with a newer version, that op is skipped while the rest of the batch commits. That's a partial application of what we introduced batches to keep atomic (e.g. for update_rgb_channel_info it could remove the old key while skipping the write of the new one, losing the channel info). Since all ops in a batch share one version and represent one logical write, I think staleness should be all-or-nothing too: if any touched key has last_applied > version, skip the entire batch. Unreachable today given callers are serialized, but the whole point of doing this at the store level is to not depend on that.
Two minor notes, no action needed unless easy: the key_locks map grows unboundedly (one entry per key ever touched), fine at our scale, maybe worth a comment; and since the batch override goes through the same block_on helper as everything else, dropping the dedicated runtime (other thread) will carry it along automatically, no coordination needed between the two changes.
On the read-modify-write gap: agreed it exists and agreed it's currently closed by caller serialization (peer_state on the LDK side, the single-threaded event handler on the RLN side). Your proposed update method follows the same shape as execute_batch, which tells me introducing it later won't change anything structural, so let's defer it and not grow this PR further. What I'd like now instead is to make the invariant explicit: a short comment on update_rgb_channel_amount and write_rgb_payment_info stating that callers must be externally serialized per key, so whoever adds a new caller knows what they're signing up for.
About the test, I think you can add a restart to the close_force_pending_htlc test that you recently added in RGB-Tools/rgb-lightning-node#133
There was a problem hiding this comment.
All done:
- batch staleness is now all-or-nothing: after taking all the key locks, if any touched key has a newer applied version the whole batch is skipped; the per-op skips are gone. Added a unit test where one stale key voids the whole batch.
- key_locks now documents that entries are intentionally never pruned: the stored version is the key's staleness watermark, dropping an entry would let a delayed older write reapply.
- added the serialization comments on update_rgb_channel_amount and write_rgb_payment_info.
- close_force_pending_htlc now restarts the payer while the colored HTLC is pending and waits for the channel to re-establish before the force close.
Agree on the batch picking up the runtime change automatically however, see the other thread first, since removing the dedicated runtime hit an sqlx-level problem.
There was a problem hiding this comment.
close_force_pending_htlc now restarts the payer while the colored HTLC is pending and waits for the channel to re-establish before the force close.
@dcorral we discussed that test and related fix should be in a separate PR (and into 2 separate commits) since this is a pre-existing bug
514cb56 to
5ac1202
Compare
8f9fc25 to
0349929
Compare
61a2697 to
eed75dc
Compare
89a8277 to
1d65fb3
Compare
1ea41c9 to
1d22fe0
Compare
1d22fe0 to
202deab
Compare
This PR adds database persistence for RGB-specific data via the
KVStoreSynctrait, replacing direct filesystem operations with KVStore calls through dependency injection.Changes
New KVStore Functions (
rgb_utils/mod.rs)Added namespace constants and persistence functions:
New functions:
read_rgb_transfer_info/write_rgb_transfer_inforead_rgb_channel_info/write_rgb_channel_info/remove_rgb_channel_inforead_rgb_payment_info/write_rgb_payment_inforead_rgb_consignment/write_rgb_consignment/remove_rgb_consignmentget_rgb_channel_info_pendingis_payment_rgbfilter_first_hopsDependency Injection
KVStore is now passed through struct fields as
Arc<dyn KVStoreSync + Send + Sync>:ChannelManager.rgb_kv_storeChannelManagerReadArgs.rgb_kv_storeChannelContext.rgb_kv_storeOutboundPayments.rgb_kv_storeKeysManager.rgb_kv_storeInMemorySigner.rgb_kv_storeUpdated RGB Functions
All RGB functions now receive KVStore directly instead of filesystem paths:
color_commitment- Reads pending channel info and payment info from KVStore, writes transfer infocolor_htlc- Reads transfer info from KVStore, writes updated transfer infocolor_closing- Reads pending channel info from KVStore, writes transfer infois_channel_rgb- Checks KVStore for channel RGB dataget_rgb_channel_info- Reads from KVStorerename_rgb_files- Renames channel info and consignment entries in KVStorehandle_funding- Writes consignment and channel info to KVStoreupdate_rgb_channel_amount- Reads/writes channel info via KVStoreProxy Key Pattern for HTLC Payments
Payment info uses a proxy key scheme for HTLC tracking:
payment_hash(hex){channel_id}_{payment_hash}{payment_hash}_pendingDesign
rgbprimary namespace with typed secondary namespacesRgbInfo,RgbPaymentInfo, andTransferInfoserialized to KVStoreBreaking Changes
The following public functions now require a
kv_storeparameter instead of filesystem paths:is_channel_rgbkv_store: &KwhereK: KVStoreSyncupdate_rgb_channel_amountkv_store: &KwhereK: KVStoreSyncwrite_rgb_payment_info_filekv_store: &KwhereK: KVStoreSync(also removedldk_data_dir)color_htlckv_store: &dyn KVStoreSynccolor_closingkv_store: &dyn KVStoreSynchandle_fundingkv_store: &KwhereK: KVStoreSyncStruct constructors (
ChannelManager,KeysManager,InMemorySigner,OutboundPayments) now require anrgb_kv_storeparameter.