Skip to content

multi: cut VTXO listing and coin selection CPU - #728

Merged
Roasbeef merged 9 commits into
mainfrom
vtxo-listing-levers
Jun 18, 2026
Merged

multi: cut VTXO listing and coin selection CPU#728
Roasbeef merged 9 commits into
mainfrom
vtxo-listing-levers

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we take the CPU cost out of the VTXO listing and coin
selection paths. Profiling the stress workload at ~10/s showed
ListVTXOsByStatus alone eating 32% of daemon CPU, almost all of it
decode work and ancestry loads on rows the caller never needed in full.

Coin selection now runs on a lightweight selection-candidate projection
instead of fully materialized VTXOs: a new sqlc query returns just the
fields selection needs, and the manager consumes that shape directly. The
immutable descriptor decode is memoized by outpoint, so the expensive
parse happens once per VTXO lifetime rather than once per listing. RPC
listings skip the ancestry load outright, and ListVTXOs callers can opt
out of checkpoint PSBT population via a new exclude_checkpoint_psbts
flag (the checkpoint PSBT package loads were the dominant share of the
listing cost). Together these took ListVTXOsByStatus from 32% to 4.8% of
CPU and killed the sqlite external-sorter temp-file spill that accounted
for 61% of raw syscalls on the profile.

Two changes here are correctness hardening that the faster listing path
made visible: live VTXOs whose session actor is gone are self-healed
during coin selection (respawned through the registry rather than failing
the selection), and spend reservations are delivered tell-style off the
manager turn so reservation delivery can't deadlock against the manager's
own mailbox.

This PR builds on #727 and is part of the OOR optimization train.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant performance optimizations to the VTXO listing and coin selection paths. It adds options to exclude checkpoint PSBTs, introduces lightweight listing and selection projection queries to skip ancestry joins, and implements an in-memory descriptor cache to memoize expensive public key parsing and taproot script reconstruction. Additionally, it transitions spend reservations to a detached, asynchronous model to prevent blocking the manager. The review feedback highlights two critical issues: first, the new vtxoDescriptorCache is not thread-safe and requires mutex protection for concurrent access; second, the asynchronous detachedReserve implementation in vtxo/manager.go incorrectly uses a timed-out context for its outcome watcher and failure reporting, which could cause failed reservations to go unreported and permanently lock liquidity.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +3 to +12
import (
"errors"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/neutrino/cache"
"github.com/lightninglabs/neutrino/cache/lru"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The vtxoDescriptorCache is accessed concurrently by multiple goroutines (e.g., during concurrent RPC requests). Since lru.Cache is not thread-safe, concurrent access to get and put will cause data races and potential memory corruption. We should add a sync.Mutex to protect the cache operations.

Suggested change
import (
"errors"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/neutrino/cache"
"github.com/lightninglabs/neutrino/cache/lru"
)
import (
"errors"
"fmt"
"sync"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/neutrino/cache"
"github.com/lightninglabs/neutrino/cache/lru"
)

Comment on lines +52 to +54
type vtxoDescriptorCache struct {
entries *lru.Cache[vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Add a sync.Mutex to the vtxoDescriptorCache struct to ensure thread-safe access to the underlying lru.Cache.

Suggested change
type vtxoDescriptorCache struct {
entries *lru.Cache[vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue]
}
type vtxoDescriptorCache struct {
mu sync.Mutex
entries *lru.Cache[vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue]
}

Comment on lines +81 to +94
func (c *vtxoDescriptorCache) get(op wire.OutPoint) (*vtxoDescriptorCacheValue,
bool) {

if c == nil || c.entries == nil {
return nil, false
}

cached, err := c.entries.Get(keyForOutpoint(op))
if err != nil {
return nil, false
}

return cached, true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Lock the mutex during cache retrieval to prevent concurrent read/write races on the LRU list.

Suggested change
func (c *vtxoDescriptorCache) get(op wire.OutPoint) (*vtxoDescriptorCacheValue,
bool) {
if c == nil || c.entries == nil {
return nil, false
}
cached, err := c.entries.Get(keyForOutpoint(op))
if err != nil {
return nil, false
}
return cached, true
}
func (c *vtxoDescriptorCache) get(op wire.OutPoint) (*vtxoDescriptorCacheValue,
bool) {
if c == nil || c.entries == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
cached, err := c.entries.Get(keyForOutpoint(op))
if err != nil {
return nil, false
}
return cached, true
}

Comment on lines +99 to +112
func (c *vtxoDescriptorCache) put(op wire.OutPoint,
value *vtxoDescriptorCacheValue) error {

if c == nil || c.entries == nil {
return nil
}

if _, err := c.entries.Put(keyForOutpoint(op), value); err != nil &&
!errors.Is(err, cache.ErrElementNotFound) {
return fmt.Errorf("put descriptor cache: %w", err)
}

return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Lock the mutex during cache insertion to prevent concurrent write races on the LRU list.

Suggested change
func (c *vtxoDescriptorCache) put(op wire.OutPoint,
value *vtxoDescriptorCacheValue) error {
if c == nil || c.entries == nil {
return nil
}
if _, err := c.entries.Put(keyForOutpoint(op), value); err != nil &&
!errors.Is(err, cache.ErrElementNotFound) {
return fmt.Errorf("put descriptor cache: %w", err)
}
return nil
}
func (c *vtxoDescriptorCache) put(op wire.OutPoint,
value *vtxoDescriptorCacheValue) error {
if c == nil || c.entries == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if _, err := c.entries.Put(keyForOutpoint(op), value); err != nil &&
!errors.Is(err, cache.ErrElementNotFound) {
return fmt.Errorf("put descriptor cache: %w", err)
}
return nil
}

Comment thread vtxo/manager.go
Comment on lines +249 to +291
func (m *Manager) detachedReserve(ctx context.Context, ref VTXOActorRef,
op wire.OutPoint, event actormsg.VTXOActorMsg, label string) {

log := m.logger(ctx)
managerRef := m.managerRef

askCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), detachedReserveTimeout,
)

future := ref.Ask(askCtx, event)
future.OnComplete(
askCtx, func(res fn.Result[actormsg.VTXOActorResp]) {
defer cancel()

_, err := res.Unpack()
if err == nil {
return
}

log.WarnS(askCtx, label+" detached reserve failed",
err,
slog.String("outpoint", op.String()),
)

if managerRef == nil {
return
}

tellErr := managerRef.Tell(
askCtx, &spendReservationFailedMsg{
Outpoint: op,
},
)
if tellErr != nil {
log.WarnS(askCtx, "Failed to report detached "+
"reserve failure", tellErr,
slog.String("outpoint", op.String()),
)
}
},
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using askCtx (which has a 30-second timeout) for both OnComplete and managerRef.Tell introduces a critical bug. If the reservation times out, askCtx is canceled. This can cause OnComplete to skip executing the callback (if it de-registers on context cancellation), or cause managerRef.Tell to fail immediately because its context is already canceled. As a result, the failure is never reported back to the manager, leaving the VTXO permanently locked in the in-memory reserved map. Using a detached context (context.WithoutCancel(ctx)) for OnComplete and Tell ensures the failure is always reliably reported.

func (m *Manager) detachedReserve(ctx context.Context, ref VTXOActorRef,
	op wire.OutPoint, event actormsg.VTXOActorMsg, label string) {

	log := m.logger(ctx)
	managerRef := m.managerRef

	detachedCtx := context.WithoutCancel(ctx)
	askCtx, cancel := context.WithTimeout(
		detachedCtx, detachedReserveTimeout,
	)

	future := ref.Ask(askCtx, event)
	future.OnComplete(
		detachedCtx, func(res fn.Result[actormsg.VTXOActorResp]) {
			defer cancel()

			_, err := res.Unpack()
			if err == nil {
				return
			}

			log.WarnS(detachedCtx, label+" detached reserve failed",
				err,
				slog.String("outpoint", op.String()),
			)

			if managerRef == nil {
				return
			}

			tellErr := managerRef.Tell(
				detachedCtx, &spendReservationFailedMsg{
					Outpoint: op,
				},
			)
			if tellErr != nil {
				log.WarnS(detachedCtx, "Failed to report detached "+
					"reserve failure", tellErr,
					slog.String("outpoint", op.String()),
				)
			}
		},
	)
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 897b8f41f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vtxo/manager.go
Comment on lines +1357 to +1363
// Reverse direction: a reservation row whose VTXO row is NOT in
// SpendingState means the owning session checkpointed but the
// detached Spending status write never landed before the shutdown.
// The session resumes on this boot and still owns the input, so
// re-mark the in-memory reservation and re-drive the reserve event
// to converge the durable status. Without this, a restarted daemon
// could select an input an in-flight session owns.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-drive reservations even when no VTXOs are Spending

When a daemon restarts after an OOR session checkpoints its reservation row but before the detached SpendReserveEvent writes SpendingState, the reserved input is still Live, so spending is empty. The early len(spending) == 0 return above this new reverse-direction recovery means ListReservedOutpoints is never read and this block never re-marks or re-drives the reservation, leaving the live row selectable by another spend even though the resumed session still owns it. Move the reservation-list/read and redrive path before that early return, or only skip the orphan-release half when there are no Spending rows.

Useful? React with 👍 / 👎.

@Roasbeef
Roasbeef force-pushed the vtxo-listing-levers branch from 897b8f4 to bb9bb3b Compare June 10, 2026 17:34
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch 3 times, most recently from 5a88bd8 to dcf9293 Compare June 15, 2026 04:20
@Roasbeef
Roasbeef force-pushed the vtxo-listing-levers branch 3 times, most recently from b9242ad to 5a4b20d Compare June 15, 2026 04:25
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch from dcf9293 to 9e6f144 Compare June 15, 2026 04:53
@Roasbeef
Roasbeef force-pushed the vtxo-listing-levers branch from 5a4b20d to 811adab Compare June 15, 2026 04:53
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch 2 times, most recently from 6be5a1d to 8e93cfb Compare June 15, 2026 05:38
@levmi levmi added the P2 Priority 2 — medium label Jun 15, 2026
@Roasbeef
Roasbeef force-pushed the durable-actor-extensions branch 2 times, most recently from 1e1ebf7 to e0b941e Compare June 16, 2026 23:20
@Roasbeef
Roasbeef changed the base branch from durable-actor-extensions to main June 17, 2026 01:53
Roasbeef added 4 commits June 16, 2026 18:56
Profiling at ~9.5 payments/s showed ListVTXOsByStatus consuming 32% of
all CPU samples, with 26% in the per-row descriptor decode. Coin
selection runs that listing on every payment but only consumes the
outpoint, amount, and pkScript columns, so this adds a projection
query returning exactly those three fields. The Go-side switch to the
projection lands separately.
Profiling at ~9.5 payments/s showed coin selection responsible for a
large slice of the hottest CPU path: selectAndReserveVTXOs listed all
live VTXOs through the full descriptor decode on every payment, paying
pubkey parsing, taproot script reconstruction, policy template decode,
and the batched ancestry query for fields it never reads. Selection
consumes exactly the outpoint, the amount, and the pkScript.

VTXOStore gains ListSelectionCandidatesByStatus, returning SelectedVTXO
projections straight from the new sqlc query. The manager wraps the
rows as minimal descriptors at its single call site so the shared
largest-first machinery (dust handling, liquidity classification) stays
unchanged; the partial descriptors never escape the selection function,
whose response already carries the same three fields.
Profiling at ~9.5 payments/s put ListVTXOsByStatus at 32% of all CPU
samples, 26% of it in rowToDescriptor: every listing re-parsed both
public keys, re-decoded the policy template (twice), and re-derived
the taproot output script with live secp256k1 point math for every
row, on every call. All of that material is immutable for a given
outpoint, since a VTXO's script is bound to its on-chain output.

rowToDescriptor now resolves the derived parts through a process-local
outpoint-keyed LRU, following the same read-only sharing contract as
the existing ancestry tree cache. Mutable row state (status and
friends) is read fresh on every call and stamped onto a new Descriptor
per row, so listings observe status transitions immediately while the
expensive derivation runs once per outpoint. The cache needs no
invalidation for the same reason it is correct: an outpoint can never
map to different script material.

This also makes the ListVTXOs RPC cheap on repeat calls, since the
handler decodes through the same store.
The materialized-VTXO notification that spawns a new VTXO's actor is
delivered asynchronously after the producing OOR session's commit, so
a coin selection racing that window observes the committed Live row
before the actor exists and fails the payment with "no actor for
outpoint". A 1000-payment stress run at ~9.3 payments/s hit the window
twice; a notification lost outright (e.g. to a crash) used to strand
the liquidity until restart.

The store row is the source of truth, so a missing resident actor must
never make committed liquidity unusable. The reserve loop now respawns
the actor from the persisted descriptor on a map miss, on the manager
goroutine where the actors map is owned. The respawn fails closed: a
store miss or a no-longer-live row surfaces as a normal reservation
error, and no actor is registered for liquidity that is not spendable.
@Roasbeef
Roasbeef force-pushed the vtxo-listing-levers branch from 811adab to 89c147f Compare June 17, 2026 02:04
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 5m 44s —— View job


Review: PR #728 — VTXO listing & coin selection CPU cuts

Read the full diff against origin/main, the new descriptor cache, the detached-reserve path, the restart sweep, and the lightweight store/sqlc projections. Overall this is a well-structured, well-commented optimization with sensible correctness hardening. The lightweight projection + descriptor memoization + ancestry-skip approach is sound and the in-memory reserved gate is correctly confined to the manager goroutine. Two of the three bot findings hold up; one looks like a false positive. Details below.


1. detachedReserve reports failure on a context that's already cancelled — valid bug (agrees with gemini)

vtxo/manager.go:251-285. The watcher and the failure-report Tell both run on askCtx, which carries the 30s deadline:

askCtx, cancel := context.WithTimeout(
        context.WithoutCancel(ctx), detachedReserveTimeout,
)
future := ref.Ask(askCtx, event)
future.OnComplete(askCtx, func(res ...) {
        defer cancel()
        ...
        tellErr := managerRef.Tell(askCtx, &spendReservationFailedMsg{...})
})

OnComplete calls future.Await(askCtx) (baselib/actor/future.go:148), so on a timeout the callback does fire with a deadline error — but by then askCtx is already DeadlineExceeded, so managerRef.Tell(askCtx, …) fails immediately and the spendReservationFailedMsg never lands. The in-memory reserved mark is then never dropped and that liquidity is locked for the process lifetime. This is exactly the timeout case where reporting matters most.

The context.WithoutCancel(ctx) base is good (the daemon-owned watch correctly survives the turn). The fix is to report on a context that isn't the timed-out one:

detachedCtx := context.WithoutCancel(ctx)
askCtx, cancel := context.WithTimeout(detachedCtx, detachedReserveTimeout)

future := ref.Ask(askCtx, event)
future.OnComplete(askCtx, func(res fn.Result[actormsg.VTXOActorResp]) {
        defer cancel()
        if _, err := res.Unpack(); err == nil {
                return
        }
        // report on detachedCtx, NOT askCtx
        managerRef.Tell(detachedCtx, &spendReservationFailedMsg{Outpoint: op})
})

Note: I'd keep askCtx on the OnComplete/Await (so the watcher still gives up after 30s if a live-but-stuck actor never responds) and only swap the Tell to detachedCtx. Gemini's suggested patch moves OnComplete onto the no-deadline detachedCtx, which removes the 30s bound on the watcher goroutine and can leak it forever against a wedged actor. Reporting via detachedCtx while keeping the watcher on askCtx fixes the bug without that regression. Fix this →

2. sweepOrphanedReservations early-returns before the reverse-direction redrive — valid bug (agrees with codex P1)

vtxo/manager.go:1321-1323. The new reverse-direction redrive (lines 1373-1410) exists to handle a session that checkpointed its reservation row but crashed before the detached SpendingState write landed — in that case the VTXO is still Live, not Spending. But:

if len(spending) == 0 {
        return
}

sits above both the ListReservedOutpoints read and the redrive loop. If every reserved outpoint is still Live (i.e. spending is empty), the function returns before reading the reservation index, so the in-memory mark is never re-set and the redrive never runs — leaving a row the resumed session still owns selectable by another spend. The early return is only a valid shortcut for the orphan-release half. Move the reservation read + redrive above it (or gate only the release loop on len(spending) == 0). Fix this →

3. vtxoDescriptorCache thread-safety — likely a false positive (disagrees with gemini)

db/vtxo_descriptor_cache.go. Gemini flags lru.Cache as not thread-safe. github.com/lightninglabs/neutrino/cache/lru.Cache carries its own internal sync.RWMutex and locks it inside Get/Put, so concurrent access is already safe. The strongest local evidence: the sibling ancestryTreeCache (db/ancestry_codec.go:75) wraps the same lru.Cache type with no external mutex and is hit concurrently on the same listing paths in production. Adding a sync.Mutex here would be redundant (and slightly pessimize by serializing around an already-locked structure). I'd leave it as-is to stay consistent with the established pattern — but since I couldn't read the vendored neutrino source in this sandbox to 100% confirm, a quick make unit pkg=./db ... -race over the concurrent listing path is worth it to close the loop. If it ever does race, ancestryTreeCache has the identical exposure and both should be fixed together, not just this one.


Other observations (non-blocking)

  • detachedReserve success-path leak is fine: on success the callback returns early but defer cancel() still fires, so the timer context is released. Good.
  • reserved map cleanup coverage looks complete: dropped on release, complete, terminate, and async failure. Worth confirming there's no path where a detached reserve succeeds, the actor later terminates without a VTXOTerminatedMsg reaching the manager, and the mark lingers — but given the FSM always routes terminal states through the manager, this seems covered.
  • ListSelectionCandidatesByStatus wrapping as partial Descriptor: the partial descriptors (only Outpoint/Amount/PkScript/Status set) are confined to selectAndReserveVTXOs and never escape — the comment says so and the code path confirms it. Just flagging that the invariant is comment-enforced, not type-enforced; if a future change returns candidates upward it'd leak zero-valued keys/tapscripts. A brief test asserting the response only carries the projection fields would lock that in.
  • //nolint:interfacebloat on VTXOStore: reasonable given the added projection method.

Testing

I reviewed statically and did not run the build/test suite in this session (the sandbox blocked module-cache inspection and go doc). Recommend make unit pkg=./vtxo and make unit pkg=./db (ideally with -race) plus the admission tests before merge.

Findings 1 and 2 are the merge-blockers; both are crash/restart-window correctness issues that the faster path made reachable.
• branch vtxo-listing-levers

@Roasbeef
Roasbeef force-pushed the vtxo-listing-levers branch from 89c147f to 0fdac36 Compare June 17, 2026 02:31
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jun 17, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 4m 27s —— View job


Re-review: PR #728 — VTXO listing & coin selection CPU cuts

Re-reviewed the current tip of vtxo-listing-levers (8 commits) against origin/main, with particular attention to whether the two merge-blockers from the earlier review were addressed. Both are now fixed in HEAD. No new merge-blockers found. A few non-blocking notes below.


Status of the earlier findings

1. detachedReserve reporting on a cancelled context — ✅ fixed. vtxo/manager.go:249-307 now derives detachedCtx := context.WithoutCancel(ctx), keeps the watcher/OnComplete bounded on askCtx (so a wedged child can't leak the goroutine past 30s), and issues the failure Tell on a fresh reportCtx off detachedCtx (manager.go:289-298). This is exactly the split the prior review recommended — the spendReservationFailedMsg now lands even in the timeout case, and TestSpendReserveRejectedWhenPendingForfeit / TestRollbackOnPartialSpendFailure assert the mark is dropped via the hop-back. Good.

2. sweepOrphanedReservations early-return above the redrive — ✅ fixed. vtxo/manager.go:1319-1437. The len(spending) == 0 short-circuit is gone; there's now an explicit comment (manager.go:1337-1343) explaining why it must not be reintroduced, and the reverse-direction redrive (manager.go:1404-1429) runs unconditionally after the reservation list is read. The "all reserved outpoints still Live" case is now handled.

3. vtxoDescriptorCache thread-safety — confirmed false positive. db/vtxo_descriptor_cache.go wraps neutrino/cache/lru.Cache, which carries its own internal RWMutex in Get/Put. The sibling ancestryTreeCache (db/ancestry_codec.go:75) wraps the identical type with no external mutex on the same concurrent listing paths, so adding one here would be redundant and inconsistent. Leaving it as-is is correct. (I couldn't open the vendored neutrino source in this sandbox to byte-confirm, so a -race pass over the concurrent listing path is still the cheap way to close the loop — and if it ever races, ancestryTreeCache has identical exposure.)


Fresh observations (all non-blocking)

  • No regression test for the redrive branch. The fix for finding multI: add initial repo scaffolding #2 is correct in code, but vtxo/manager_admission_test.go has no test exercising sweepOrphanedReservations with a reservation row whose VTXO is still Live and spending empty — i.e. the exact branch the early-return used to skip. Since this is a restart-window correctness path that was already broken once, it's worth a direct test asserting markReserved + the SpendReserveEvent redrive fire. Add a test →

  • Descriptor cache returns aliased reference fields. db/vtxo_store.go:757-766 sets PolicyTemplate (a []byte) and TapScript (a *waddrmgr.Tapscript) directly from the shared vtxoDescriptorCacheValue. The insert path clones the row's policy bytes (vtxo_store.go:783), so sqlc's buffer isn't aliased — good — but every rowToDescriptor hit then hands out the same slice/pointer. The read-only contract is documented (vtxo_descriptor_cache.go:27-28) and matches ancestryTreeCache, but it's comment-enforced, not type-enforced: a future consumer that appends to desc.PolicyTemplate in place would corrupt the cache for every other reader. Worth keeping in mind if these descriptors ever flow into a mutating path.

  • Redrive Tell failure leaves the in-memory mark set (vtxo/manager.go:1417-1426): if the SpendReserveEvent Tell errors, markReserved(op) has already run and the mark lingers with no FSM progress. This is the safe direction (the resumed session still owns the input, so blocking selection is correct, and the durable row re-drives on the next boot), so it's fine as-is — just flagging that the warning log is the only signal.

  • Partial-descriptor projection is still comment-confined. selectAndReserveVTXOs (manager.go:1081-1093) wraps projection rows as Descriptors with only Outpoint/Amount/PkScript/Status set; the comment asserts they never escape, and the path confirms it (the response is built from SelectedVTXO). As noted last round, this invariant would benefit from a test asserting the selection response carries only projection fields, so a future change that returns candidates upward can't silently leak zero-valued keys/tapscripts.


Testing

Static review only — go/make invocations require approval in this sandbox, so I did not run the build or suite. Recommend before merge:

  • make unit pkg=./vtxo and make unit pkg=./db (ideally -race over the concurrent listing/cache path)
  • the admission tests in vtxo/manager_admission_test.go

Summary: the two correctness blockers from the prior review are resolved in HEAD; the remaining items are test-coverage and invariant-hardening suggestions, none blocking.

  • Read the diff against origin/main
  • Review descriptor cache + lightweight projections
  • Review detached reserve / restart sweep correctness
  • Post review findings
    • branch vtxo-listing-levers

Profiling after the listing levers showed the system wait-bound, not
CPU-bound: 9.5 payments/s with 16 workers works out to an effective
parallelism of ~1.4 by Little's law, with zero pacing and zero
liquidity waits. The serializer is the manager goroutine, which held
every payment's selection turn across a synchronous Ask per selected
input, each one a mailbox hop plus an FSM turn plus a write
transaction committing VTXOStatusSpending.

Spend reservations are now detached. The manager marks the outpoint
in a goroutine-owned in-memory reservation map, issues the Ask, and
observes the child's future via OnComplete on a detached goroutine
instead of awaiting it, so the selection turn ends in microseconds. A
failed reservation (the candidate raced out of LiveState) hops back
as a manager-internal message that drops the mark; the owning session
then fails at signing or submit and retries through the existing
machinery. The map gates both spend and forfeit admission ahead of
the durable status, since a detached Spending write may still be in
flight when the next selection lists candidates.

Crash safety rests where it already rested: the spending_reservations
index is written by the OOR session's checkpoint, and the startup
sweep releases Spending rows with no reservation row. The sweep gains
the reverse reconcile for the new write ordering: a reservation row
whose VTXO row is still Live means the owner checkpointed before the
detached status write landed, so the boot re-marks the reservation
and re-drives the reserve event.

The forfeit path keeps its synchronous ask: round participation wants
the durable state settled before proceeding, and it is not on the
payment hot path. Admission tests are adapted to the detached
contract, including the asynchronous failure hop-back and the
post-restart actor-level rejection arm.
Roasbeef added 4 commits June 17, 2026 16:26
Profiling the OOR stress workload at its post-optimization rate shows
ListVTXOs at 31% of daemon CPU, and 21% of that is attaching the
finalized OOR checkpoint PSBTs to every returned VTXO: each one costs a
package lookup in the artifact store, which re-reads the PSBT blobs
from disk on every call. Listing-only consumers such as balance views
and coin selection never inspect those PSBTs, yet they pay the full
price on every poll.

In this commit, we add an exclude_checkpoint_psbts field to
ListVTXOsRequest so those callers can opt out of the package loads.
The default keeps the full response, so existing consumers (the SDK
VTXO model and the swap lookup path) are unaffected.
In this commit, we honor the new exclude_checkpoint_psbts request
field in the ListVTXOs handler: when set, the handler never opens the
OOR artifact store, so no per-VTXO package reads happen at all. This
removes the dominant CPU cost of high-frequency live-VTXO polling
during the stress workload while leaving the default response shape
untouched.
Profiling the stress workload at ~11.6 payments/s shows the ListVTXOs
RPC still at ~40% of daemon CPU after the checkpoint-PSBT opt-out, now
inside ListVTXOAncestryPathsByStatus: the batched ancestry join drags
every VTXO's TLV tree fragments through SQLite's external merge sorter,
and those blobs grow with OOR chain depth, so the sorter spills PMA
files to disk on every listing call (~61% of CPU samples were the
resulting pwrite syscalls).

The listing response never carries ancestry: only the unroller walks
lineage, which is exactly the split migration 000009 introduced the
side table for. In this commit, we add ListVTXOsByStatusLight and
ListLiveVTXOsLight to the VTXO persistence store, identical to their
full counterparts except the ancestry side table is never touched, and
switch the ListVTXOs RPC handler to them unconditionally. The VTXO
manager's recovery and exit paths keep the full listings since their
descriptors do feed unroll.
A manual unroll of a VTXO mid-OOR-spend escalates SpendingState to
UnilateralExitState, but the emitted VTXOStatusUpdate omitted the
ReleaseSpendReservation flag the sibling critical-expiry branch sets, so
the durable spending-reservation row was left behind even though its
comment claimed the same outbox shape.

A stale row was previously benign, overwritten by the next legitimate
spend. With the startup reservation sweep's reverse direction, a restart
that rolls a recoverable exit back to LiveState now sees the orphan row,
re-marks the recovered VTXO reserved, and re-drives SpendReserveEvent on
an input no session owns, stranding it in SpendingState. Set the flag so
the row is deleted atomically with the status change, and assert it in
the SpendingState ForceUnroll transition test.
@Roasbeef
Roasbeef force-pushed the vtxo-listing-levers branch from 0fdac36 to 54e8a77 Compare June 17, 2026 23:26
@Roasbeef
Roasbeef merged commit 6ed1d62 into main Jun 18, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Priority 2 — medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants