Skip to content

multi: harden write paths for snapshot isolation - #10998

Open
Roasbeef wants to merge 10 commits into
lightningnetwork:masterfrom
Roasbeef:rr-write-hardening
Open

multi: harden write paths for snapshot isolation#10998
Roasbeef wants to merge 10 commits into
lightningnetwork:masterfrom
Roasbeef:rr-write-hardening

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we harden the handful of write paths that a full audit of lnd's database transaction call sites flagged as unsafe under snapshot isolation, in preparation for eventually running write transactions at REPEATABLE READ on Postgres. This builds on #10997 (which moves read-only transactions to REPEATABLE READ), so only the last four commits are new here until that PR lands.

The audit classified roughly 340 write and read transaction closures across the codebase. Nearly all of them are already safe under snapshot isolation: their invariants are protected by same-row write conflicts (which still abort with a retryable 40001 under REPEATABLE READ), by in-process mutexes, or by unique constraints. Four shapes survived adversarial review, and each gets a targeted fix here. Every fix is correct and non-regressive at the current SERIALIZABLE level too, so this PR can land on its own merits.

The fixes

In channeldb, syncNewChannel used to return early when the peer's link node already existed, reading a row it never wrote. A concurrent MarkChanFullyClosed deleting that link node would leave an open channel with no link node under snapshot isolation. We now re-put the existing bytes verbatim, so the row joins the write set and the two transactions conflict on it.

In graph/db, PruneGraphNodes was the only graph mutator that didn't take cacheMu before its transaction, so its range-scan-then-delete could interleave with a concurrent addChannelEdge and leave an edge referencing a deleted node. It now takes the mutex like its siblings.

In kvdb/sqlbase, bucket creation was a select followed by a bare insert. Losing a creation race today produces a retryable 40001 (the select's predicate lock turns the duplicate into a rw-conflict), but under REPEATABLE READ it produces a 23505 unique violation instead, which the retry loop correctly refuses to retry, so it would surface as a hard error on the channel open path. The insert is now phrased as a no-op upsert whose conflict targets match the two partial unique indexes exactly, which the database reports as a retryable serialization failure in both isolation levels. A new Postgres concurrency test pins down all four combinations (bare insert vs upsert, SERIALIZABLE vs REPEATABLE READ) empirically.

In wtdb, we found and fixed a session leak that exists today even under SERIALIZABLE: a session that acks its first update for a channel after that channel was marked closed is simply never evaluated for closability, and if that was its last channel it holds tower storage forever. AckUpdate now evaluates its own session when the channel is already closed. The concurrent variant of the same hole (genuine write skew between AckUpdate and MarkChannelClosed, including a session-level variant where a session could be deleted while still owing updates) is closed by having both sides touch a shared row, so one of them retries and observes the other. The touches re-write byte-identical values, so DB dumps are unaffected.

The last commit surfaces PgError.Detail on classified Postgres errors, since today an operator cannot tell an SSI-only abort (detail mentions a pivot or rw-conflict) apart from an ordinary write-write conflict (detail says concurrent update) in any lnd log. That's exactly the signal that tells us which paths still rely on SERIALIZABLE, and it's a separable commit if reviewers would rather drop it.

With this PR and #10997 in, the remaining step for the isolation work is flipping write transactions to REPEATABLE READ behind a config option, which we'll propose separately once these have had soak time.

See each commit message for a detailed description w.r.t the incremental changes.

Related to #10995.

Roasbeef added 10 commits July 27, 2026 13:27
In this commit, we relax the isolation level used for read-only
transactions on the shared SQL kvdb backend when it's backed by
Postgres. Read-write transactions are untouched and remain
SERIALIZABLE.

lnd currently opens every transaction at SERIALIZABLE. Under Postgres'
serializable snapshot isolation, even a read-only transaction takes
SIRead predicate locks and fully participates in the serialization
conflict graph, so it can both suffer a 40001 abort and cause one for a
concurrent writer by acting as a pivot. Since lnd is extremely read
heavy, that adds up to a lot of needless abort pressure.

REPEATABLE READ in Postgres is snapshot isolation. A read-only
transaction still reads from a single consistent snapshot for its whole
lifetime, taken when its first statement runs rather than at BEGIN. This
is a genuine, if modest, weakening: that snapshot is no longer
guaranteed to correspond to a serial ordering of the writers running
alongside it, so the read-only transaction anomaly of Fekete and O'Neil
becomes possible again. We're fine with that, because these read paths
only ever consume a point-in-time view of the database, much like
bbolt's View transactions do, and a read feeding a later write in a
separate transaction was never protected across that boundary at any
isolation level.

What we get in return is that the transaction takes no part in the SSI
conflict graph at all: no SIRead predicate locks, no exposure to SSI
serialization failures, and no chance of aborting a concurrent writer as
a pivot.

The sqlbase package is shared with the SQLite backend, so we gate this
on the driver name. SQLite is always effectively serializable and its
driver ignores the requested isolation level entirely, so there's
nothing to gain there.
In this commit, we apply the same isolation level change to the native
SQL backend that the previous commit applied to the kvdb SQL backend:
read-only transactions on Postgres now run at REPEATABLE READ, while
read-write transactions stay SERIALIZABLE.

A read-only transaction still reads from a single consistent snapshot
for its whole lifetime, taken at its first statement. It gives up the
guarantee that the snapshot corresponds to a serial ordering of the
concurrent writers, which these read paths never relied on, and in
return stops participating in Postgres' serializable snapshot isolation
conflict graph: no SIRead predicate locks, no exposure to SSI
serialization failures, and no aborting a concurrent writer as a pivot.

BaseDB is also used for SQLite, so it needs to know which backend it's
talking to. We add a BackendType to BaseDB mirroring the one that
sqldb/v2 already carries, and set it at the two construction sites.
In this commit, we mirror the isolation level change into sqldb/v2.
BaseDB already carries a BackendType here, so we can gate directly on
it: read-only transactions on Postgres are opened at REPEATABLE READ,
everything else stays SERIALIZABLE.
In this commit, we add the release notes entry for the isolation level
change, along with an operator facing note in docs/postgres.md.

The latter spells out that reads now run under snapshot isolation, and
warns that lnd holds some read transactions open for a long time. The
graph cache load and each pathfinding GraphSession keep a snapshot
pinned for their whole duration, which is worth knowing before setting
idle_in_transaction_session_timeout or statement_timeout.
In this commit, we make syncNewChannel write the peer's link node row
unconditionally, rather than returning early when a link node already
exists for that peer.

The link node prune paths (MarkChanFullyClosed and pruneLinkNode) read
the peer's set of open channels and, when that set is empty, delete the
peer's link node. A concurrent channel open reads the link node row,
sees that it's already there, and skips the write. Each transaction
reads what the other writes, but their write sets are disjoint, which is
the classic write skew shape: under snapshot isolation both transactions
commit, and we're left with an open channel whose peer has no link node.

Serializable isolation catches this today, but we're preparing to run
write transactions at repeatable read, so instead we make the two
transactions collide on the same row. The write is a verbatim re-write
of the existing record, so any state that has accumulated for the link
node (extra addresses, last seen time) is preserved exactly, and the
only observable difference is that one of the two racing transactions
now aborts with a retryable serialization error.

We also update the isolation assumptions spelled out in the comments of
the two prune paths.
In this commit, we make PruneGraphNodes acquire the store's cache mutex
before it opens its write transaction. It was the only mutator of the
graph that didn't, in either of the two store implementations, while all
of its siblings already do.

That mutex does more than guard the reject and channel caches: it's also
the in-process serialization point against the batched channel edge
insertion path. addChannelEdge reads the row of each node an edge
attaches to, but doesn't write it. PruneGraphNodes range scans the node
bucket and then deletes the nodes it finds to be unreferenced. With no
mutual exclusion, the two can interleave such that the prune deletes a
node that the edge being added still references, which leaves a dangling
edge in the graph. Under serializable isolation the database would abort
one of the two, but under snapshot isolation their write sets are
disjoint and both commit.

The lock ordering here is cacheMu -> DB, which is what the rest of both
stores already does. The in-memory graph cache is updated by the
ChannelGraph wrapper once the store call returns, exactly as it is for
PruneGraph, so nothing further is needed here.
In this commit, we rework the bucket creation path of the SQL kvdb
backends so that two transactions racing to create the same bucket no
longer leave the loser with a unique constraint violation.

Both CreateBucket and CreateBucketIfNotExists used to select the row for
the key, and then, if nothing came back, run a bare insert. Under
serializable isolation that's fine: the select takes a predicate lock on
the index page, so the concurrent insert of the same key shows up as a
read/write conflict and the loser is aborted with a retryable
serialization failure. Under repeatable read there is no predicate lock,
so the loser instead runs head first into the unique index and gets back
a 23505 unique violation. MapSQLError turns that into
ErrSQLUniqueConstraintViolation, which ExecuteSQLTransactionWithRetry
does not retry, so it would be handed straight back to the caller. Since
CreateTopLevelBucket delegates here, that's a hard error on paths as
central as opening a channel.

We instead phrase the insert as an upsert whose update is a no-op. The
conflict is then reported as a serialization failure at both isolation
levels, which the retry loop knows how to handle: on the retry, the
select at the top finds the winner's row. The conflict target of each
statement has to line up with the partial unique index that covers the
row being inserted, of which there are two, one for top level rows and
one for nested rows, so we keep the two flavours of the statement apart
just like Put already does.

The walletdb semantics are unchanged: CreateBucketIfNotExists returns the
existing bucket, CreateBucket returns ErrBucketExists when the bucket is
already there (including when it lost the race, since the retry then
finds it), and both return ErrIncompatibleValue when the key holds a
value rather than a bucket.

Two tests are added against the embedded postgres fixture. The first
races several transactions creating the same bucket through the real
code path and asserts that they all succeed with a single row created.
The second races the raw statements at both serializable and repeatable
read, and pins down the failure modes: the bare insert yields 23505
under repeatable read, while the upsert yields 40001 everywhere.
In this commit, we close a hole in the bookkeeping that decides when a
watchtower client session may be deleted. A session is only ever deleted
once it has been found closable, and closability is only ever evaluated
in MarkChannelClosed, which walks the set of sessions that have acked an
update for the channel being closed. A session joins that set the first
time it acks an update for the channel, from its own sessionQueue
goroutine, which shares no lock with the channel close path.

So a session that acks its first update for a channel after that channel
was closed is never evaluated for it. If that channel was the last open
channel of the session, then nothing will ever mark the session closable
and it holds on to the tower's storage forever. This is reachable today
purely on ordering, without any concurrency involved.

We fix it by evaluating the session in AckUpdate itself whenever the
channel it acked an update for is already closed. Like the rogue update
path right above it, this only records the session in the closable
sessions bucket, so it is picked up by the closable session handler on
the next startup rather than being handed to the caller. The evaluation
is best effort: an AckUpdate that fails takes down the session queue
that issued it, and the ack itself must be persisted no matter what, so
a failure to work out whether the session has become closable is logged
rather than returned.

The rogue update branch has the same hole, and it isn't enough there to
only consider a session when the rogue count saturates it. A rogue ack
that exhausts a session whose other channels have all been closed makes
that session closable just as well, so we let that branch fall through
to the same evaluation, which subsumes the marking it did inline.

That leaves the concurrent version of the same problem. The channel's
set of sessions is only read by MarkChannelClosed and only added to by
AckUpdate, and the closed height is the other way around, so the two
transactions read what the other writes while writing disjoint rows.
That's write skew: serializable isolation aborts one of them, but under
snapshot isolation both commit, each having assumed the other would
evaluate the session. The same shape exists one level down, between
MarkChannelClosed's evaluation of a session and the concurrent
CommitUpdate or AckUpdate that changes the very state that evaluation
reads. There the consequence is worse than a leak: a session could be
marked closable off a view that has already been invalidated, and then
deleted while it still has updates to make good on.

Both are turned into same-row conflicts by writing a row back with the
value it already holds, which puts a row that a transaction would
otherwise only read into its write set:

  - AckUpdate and MarkChannelClosed both write the channel's db-ID row,
    so a close can't race the addition of a session to the channel.

  - MarkChannelClosed writes the body row of every session it evaluates,
    which is the row that CommitUpdate and AckUpdate both write, so an
    evaluation can't race a change to the session it is evaluating.

The loser of either race is aborted with a retryable serialization
error, and on the retry it observes the state the winner left behind:
AckUpdate then sees the channel as closed and evaluates its own session,
while MarkChannelClosed sees the new session in the channel's set.

Making retries of AckUpdate more likely brings a pre-existing problem
with it, which we fix here as well. RangeIndex.Add applies its changes
to the in-memory range index as soon as the key/value changes have been
staged, but that index is cached on the ClientDB and outlives the
transaction. A retry on top of an index that a rolled back attempt has
already mutated finds the height covered, stages nothing, and commits an
ack that made it into every part of the database except the range index
it belongs in, after which IsAcked reports an update as backed up that
isn't. AckUpdate now drops the range index it dirtied before each retry
and after giving up for good, so the next reader loads it back from the
database.

The invariant the two of them now maintain together is spelled out on
MarkChannelClosed.
In this commit, we fold the Detail field of a Postgres error into the
error we hand back from MapSQLError, but only for the errors that report
a transaction conflict. The error string that the driver builds leaves
that field out, which is unfortunate because for a conflict it is the
only thing that says which kind of conflict was actually hit.

A plain write-write conflict reports a concurrent update, whereas an
abort that only serializable isolation raises spells out the reason code
of the pivot that was cancelled. Being able to tell the two apart in the
logs tells an operator how much of their abort pressure would go away by
running write transactions at repeatable read, and how much of it is
real contention that would remain.

The detail of any other error is deliberately left alone. For a
constraint violation in particular, Postgres spells out the offending
column values, and in our schemas that means payment session keys,
invoice hashes and raw key/value bucket keys. None of that belongs in a
log line, let alone in an error that may travel back over an RPC.

The error parsing exists twice in each module, once for the builds that
have SQLite and once for the ones that don't, so the helper is put in a
file of its own that carries no build tags rather than being written out
twice. While here, the copy that predates SQLite is brought back in line
with its twin, which it had drifted from: it did not classify a failed
SQL transaction or a detected deadlock as a serialization error at all,
so neither would have been retried on the platforms that use it.
In this commit, we add a release notes entry for the four write paths
that were hardened so that read-write Postgres transactions can move to
repeatable read, which is the other half of the change that already put
read-only transactions there in lightningnetwork#10997.
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.

1 participant