Skip to content

db: run read-only Postgres transactions at REPEATABLE READ - #1057

Merged
Roasbeef merged 6 commits into
mainfrom
db/readonly-repeatable-read
Jul 29, 2026
Merged

db: run read-only Postgres transactions at REPEATABLE READ#1057
Roasbeef merged 6 commits into
mainfrom
db/readonly-repeatable-read

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jul 28, 2026

Copy link
Copy Markdown
Member

In this PR, we relax the transaction isolation level for read-only Postgres
transactions from SERIALIZABLE down to a read-only REPEATABLE READ, and
leave writers exactly where they are.

Today BaseDB.BeginTx hardcodes sql.LevelSerializable for every transaction
we open, reads included. Under Postgres' serializable snapshot isolation that
isn't free for a reader: a read-only SERIALIZABLE transaction still takes
SIRead predicate locks and still joins the serialization conflict graph, so it
can both eat a 40001 itself and act as the pivot that aborts a concurrent
writer. On a daemon as read heavy as ours, that's pure cost. A signet
measurement put us at 2,035 transaction commits per second with 99.6% of them
changing no rows at all, against a Postgres box sitting at 794m of a one core
limit largely on predicate-lock bookkeeping.

For a concrete version of that cost, the lumos client-connection ingress
long-poll is a read path paying the SSI toll for nothing at all. Each
pullIteration in db/mailbox_store.go opens a ReadTxOption() transaction
around PullMailboxEnvelopes and only ever runs a SELECT. The loop is
wake-driven rather than a fixed-rate poll, since it selects on a notify channel
that Append fires, so the poll timer is a fallback and not the mechanism. The
trouble was that the fallback interval was one second against a five second
wait window, and the knob for it was entirely unwired: WithPullPollInterval
had no production callers, there was no config field behind it, and the store
options builder never emitted it. So one second is flatly what ran on signet,
with no deployment able to have tuned it.

Worth stating the ordering, since the constant is moving. Before that fix, each
five second long-poll cost five read transactions, on the order of 290 per
second across the parked ingress loops in NobleSun's signet profile. Raising
the fallback above the wait window takes it to one per RPC, roughly 58 per
second. That change reduces how many of these transactions exist and this PR
reduces what each one costs, so the two compose rather than overlap, and
neither owns the combined win.

The first commit is the whole functional change, and it's deliberately
self-contained and independently landable. It follows
lightningnetwork/lnd#10997. The isolation choice funnels through a small
txIsolationLevel helper that gates on the BackendType that BaseDB already
carries, so SQLite is untouched. There's nothing to win there anyway, since
SQLite only ever admits a single writer and is effectively serializable already.

Worth calling out that we open these transactions as READ ONLY as well as at
the relaxed level, and the flag is doing at least as much work as the level is.
Postgres only skips predicate-lock acquisition for a transaction that is
genuinely declared read only, so the level on its own would buy us nothing. We
were already passing that flag through, which is why this lands as two lines.

The second commit surfaces the ConstraintName and Detail fields of a
pgconn.PgError in the message our mapped error types render. PgError.Error
prints only the severity, the message and the SQLSTATE, so both fields were
going on the floor before they reached a log line. The detail is the only thing
that tells two very different 40001 aborts apart: a true SSI abort carries a
reason code naming the transaction's role in the conflict graph, while an
ordinary write-write conflict on the same row carries no detail at all. Now that
readers no longer take predicate locks, that's the signal that says whether a
given write path still leans on SSI or would be equally happy at REPEATABLE READ. The constraint name matters for a different reason, covered below.

The third commit is docs/postgres_isolation.md, which writes down the policy,
the operator caveats, and the audit that decided where the boundary sits.

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

Why writers stay at SERIALIZABLE

This one is the actual deliverable rather than a shortfall. The task was scoped
to go all the way and move writers too, and the audit came back saying we
shouldn't. Both halves of that judgement matter.

The upside is small. Writers are a rounding error in this workload. If 99.6% of
transactions change nothing, then relaxing the remaining 0.4% can't recover much
beyond what the read-only change already takes off the table. The read side was
the entire problem.

The risk is not small. An audit of all 64 write closures found fourteen sites of
concern. Eleven of those genuinely depend on the serialization graph and would
break if we relaxed the level, and several of them sit in fund-critical paths.
For calibration, the comparable audit in lnd found four such shapes across about
340 closures, so even counting the eleven alone our hit rate is far denser. The
full table lives in docs/postgres_isolation.md rather than here, classified
into three shapes: read-check-then-write, a lost creation race that surfaces as
a 23505, and write skew.

Two structural blockers compound it. Under REPEATABLE READ the same-row
40001 becomes the only conflict signal we have left, but
TxAwareActorDeliveryStore.ExecTx has no retry loop at all, and
TransactionExecutor.ExecTx skips its own whenever it joins an ambient actor
transaction, which is the normal case for the ledger, audit, credit and activity
stores. So the one signal that would have to absorb the change isn't retried on
the dominant code path. Separately, a lost creation race that SSI reports as a
retryable 40001 can become a 23505 unique violation under REPEATABLE READ,
and a retry loop correctly refuses to retry that.

The doc also inventories the six partial unique indexes in the schema, which is
useful well beyond this change. An ON CONFLICT target that doesn't match a
partial index predicate never arbitrates against that index, so the upsert
quietly degrades into a plain insert and you find out about it later.

Unlike lnd#10999 we don't add a configuration knob for the write level. A knob
whose non-default setting silently enables about a dozen known anomalies is a
loaded gun with a safety catch rather than a feature. It should become
configurable once those paths are hardened, and not before.

To be explicit about this, since the audit table reads like a bug list: this PR
doesn't break any of those sites. Writers keep talking to each other through the
full conflict graph exactly as before, because Postgres tracks dependencies only
among serializable transactions, and a read-only REPEATABLE READ transaction
was never able to protect a write in a different transaction anyway. The table
is a record of what would have to be fixed before writers could move, not of
what this change breaks.

The original draft went further and said that none of the audited sites is a
live bug today, on the grounds that SSI is masking all of them. An adversarial
pass over the table caught that, and it turns out to be half wrong in a way
worth knowing about. SSI promotes a lost creation race to a retryable 40001
only when the losing transaction read the contested key before inserting it,
since that read is what leaves the predicate lock the conflict graph is built
from. Three of the fourteen sites insert blind, so there's no dependency for
SSI to find and they already lose with a bare 23505 at SERIALIZABLE today.
Those three are reachable now, independently of this work, and relaxing the
level would take nothing away from them. That's why the audit table now splits
shape B into read-check and blind, and why the count above is eleven rather than
fourteen. Both halves of the mechanism are pinned against a real Postgres in
TestPostgresConflictShapes rather than left as an argument. The remaining
eleven are genuinely masked today.

What we're giving up

Snapshot isolation is not serializability, and this is a real if modest
weakening for readers. A read-only REPEATABLE READ transaction is no longer
guaranteed to observe a state corresponding to some serial ordering of the
writers running alongside it, so the read-only transaction anomaly described by
Fekete and O'Neil is once again permitted. The argument in the code comment is
that our read paths only ever consume a point-in-time view and never depended on
being ordered against writers in other transactions, and that a read feeding a
later write in a separate transaction was never protected across that boundary
at any isolation level. That claim is worth a reviewer checking rather than
taking at face value, since it's what the whole change rests on.

There's an operator consequence too, written up in the doc. A REPEATABLE READ
transaction pins its snapshot for its entire lifetime, and Postgres can't vacuum
row versions still visible to an open snapshot. Some of our read transactions
are long lived: the ancestry resolver walks a tree, and several round and VTXO
listings iterate large result sets. A slow one now delays cleanup and can bloat
tables, and it sits idle in transaction while the daemon computes between
queries, so idle_in_transaction_session_timeout and statement_timeout need
to be generous enough to cover a full pass. None of these hold a transaction
open across a network call or an actor send, so their duration is bounded by
database and CPU work rather than by a remote peer.

What was and wasn't verified locally

The backend gate and the level selection are covered by a plain unit test that
runs everywhere. Everything that asserts on Postgres' own behaviour sits behind
the test_postgres build tag and wants a Docker Postgres, and those have been
run against a real fixture rather than left as an aspiration. They cover that
the requested options survive the pgx stdlib driver and reach the server as a
read-only REPEATABLE READ transaction, that the server rejects a write inside
one, that an SSI pivot abort carries a reason code, that a REPEATABLE READ
same-row conflict still raises a retryable 40001 with no detail, that a
REPEATABLE READ lost creation race raises a non-retryable 23505 instead,
and that under SERIALIZABLE the same race raises a 23505 when the loser
inserts blind but a 40001 with a pivot reason code when it reads first. The
isolation assertion reads back SHOW transaction_isolation and SHOW transaction_read_only, so it's the server's own view of the transaction rather
than a restatement of what we asked for.

What isn't verified here: the signet numbers are a reported measurement rather
than something reproducible from this branch, and the write-path audit was
produced by reading code rather than by running anything against a live
database. The claim that no read path depends on cross-transaction
serializability is reasoned from the call sites in the same way, and isn't
something a test can pin down.

Cross references

The retry gap named above is partly closed by #1053, which adds a retry loop to
the actor commit transaction. lightninglabs/lumos#718 and
lightninglabs/swapdk-server#265 are the incident this is meant to take pressure
off.

Roasbeef added 3 commits July 28, 2026 14:17
In this commit, we relax the isolation level for read-only Postgres
transactions from SERIALIZABLE down to REPEATABLE READ. Read-write
transactions are untouched and stay SERIALIZABLE.

Every transaction we open today runs 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 itself and act as
the pivot that causes a concurrent writer to be aborted. The daemon is
extremely read heavy, so that adds up to a lot of needless abort
pressure. On signet we measured 2,035 transaction commits per second
with 99.6% of them changing nothing at all, against a Postgres instance
sitting at 794m of a one core limit largely on predicate-lock
bookkeeping.

A read-only REPEATABLE READ transaction observes a single consistent
snapshot taken when its first statement runs, which is precisely what
our read paths already consume. What changes is that such a transaction
takes no predicate locks, can never fail with a serialization error, and
no longer appears in anyone else's conflict graph.

Note that the READ ONLY access mode is not new here. We were already
passing it through, so Postgres was already refusing writes in these
transactions. That matters because Postgres only skips predicate lock
acquisition for a transaction that is genuinely declared read only, so
the access mode we already had is what makes the relaxed isolation level
worth anything at all.

This step stands on its own. Writers keep talking to each other through
the full SSI conflict graph exactly as before, because Postgres tracks
dependencies only among serializable transactions and a read-only
REPEATABLE READ transaction was never able to protect a write in a
different transaction anyway.

The isolation choice funnels through a small txIsolationLevel helper
that carries the reasoning. BaseDB also backs SQLite, where isolation
levels behave differently and there is nothing to gain because the
engine only ever admits a single writer, so the helper gates on the
BackendType that BaseDB already carries and leaves every other
combination fully serializable.
In this commit, we include the ConstraintName and Detail fields of a
Postgres error in the message our mapped error types render, and add an
IsUniqueConstraintViolation predicate to sit alongside the existing
serialization and deadlock classifiers.

The Error method of pgconn.PgError prints only the severity, the
message and the SQLSTATE code, so both fields were being dropped on the
floor before they ever reached a log line. That is a problem in two
directions.

The detail is the only thing that tells two very different 40001 aborts
apart. A true serializable snapshot isolation abort carries a reason
code naming the transaction's role in the conflict graph, while an
ordinary write-write conflict on the same row carries no detail at all.
Now that read-only transactions no longer take predicate locks, this is
the signal that says whether a given write path still depends on SSI or
would be equally happy at REPEATABLE READ, which is what we need in
order to measure the effect of that change.

The constraint name matters because the schema carries six partial
unique indexes. Without it a 23505 raised by any of them is
indistinguishable from a 23505 raised by the table's primary key, and
telling those apart is the whole diagnostic value of the error.

The new tests pin down the three conflict shapes that this work turns
on, each against a real Postgres. A serializable read-write dependency
cycle aborts with a reason code. A REPEATABLE READ write-write conflict
on the same row still aborts as a retryable 40001 but carries no
detail, which is the property that lets a shared row stand in for an
SSI dependency. A REPEATABLE READ lost creation race does not abort at
all and instead raises a 23505 that our retry predicate correctly
refuses to retry.
In this commit, we write down the isolation level policy for the
Postgres backend, the reasoning that produced it, and the audit of the
write paths that decided where the boundary sits.

The policy itself is two lines of code, but the reasoning behind it is
not, and most of that reasoning is about paths the code does not touch.
Read-only transactions now run at REPEATABLE READ. Writers stay at
SERIALIZABLE, and this explains why they should stay there for now.

The short version of the argument is that the upside of relaxing
writers is small and the risk is not. Writers are a rounding error in
this workload, so relaxing the 0.4% of transactions that change
anything cannot recover much of the predicate-lock cost that the
read-only change already removes. Against that, an audit of all 64
write closures found roughly a dozen that genuinely lean on the
serialization graph, a far denser hit rate than the comparable audit in
lnd, and several of them sit in fund-critical paths. None of them is a
live bug today, because SSI is currently masking every one.

Two structural problems compound this, and both are recorded here.
Under REPEATABLE READ the same-row 40001 becomes the only remaining
conflict signal, yet the actor-delivery store has no retry loop and the
generic executor skips its own retry loop whenever it joins an ambient
actor transaction, which is the normal case for the stores that write
most often. Separately, a lost creation race stops being a retryable
40001 and becomes a 23505 that the retry loop correctly refuses to
retry.

We also deliberately do not add the configuration knob that lnd shipped
for this. A knob whose non-default setting silently enables about a
dozen known anomalies is a loaded gun with a safety catch rather than a
feature, and it should wait until the paths listed here are hardened.

The audit table and the inventory of the six partial unique indexes are
included so the work does not have to be redone. The index inventory
matters on its own, because a conflict target that does not match a
partial index predicate never fires and the upsert quietly degrades
back into a plain insert.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@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: 0075c21165

ℹ️ 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 on lines +82 to +83
ctx, "INSERT INTO chain_info (id, chain_name, "+
"genesis_hash) VALUES (2, 'nope', '\\x01')",

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 Move raw test SQL into generated queries

This test adds a literal INSERT through ExecContext, and db/sqlerrors_postgres_test.go similarly adds literal SELECT, INSERT, and UPDATE statements. The package rules prohibit raw SQL in Go and require queries to be added to the query definitions and regenerated, so these isolation probes should use generated query methods or another sanctioned fixture instead.

AGENTS.md reference: db/AGENTS.md:L114-L115

Useful? React with 👍 / 👎.

Comment thread db/sqlerrors.go
Comment on lines +127 to +129
func PgErrorDetail(err error) string {
var pgErrV4 *pgconnv4.PgError
if errors.As(err, &pgErrV4) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unique-error causes for metadata extraction

When a caller passes the error returned by TransactionExecutor.ExecTx, a PostgreSQL 23505 has already been wrapped in ErrSQLUniqueConstraintViolation by MapSQLError. That wrapper has no Unwrap method, so errors.As here cannot reach the *pgconn.PgError; consequently both new metadata extractors return empty values for the mapped store errors they are intended to diagnose. The tests miss this because they call the extractors with the raw driver error. Add an Unwrap method to ErrSQLUniqueConstraintViolation, as the serialization and deadlock wrappers already do.

Useful? React with 👍 / 👎.

Roasbeef added 2 commits July 28, 2026 14:54
In this commit, we add two subtests to TestPostgresConflictShapes that
split the lost creation race by whether the losing transaction read the
contested key before inserting it. The existing subtests covered the
REPEATABLE READ side of that race but assumed, rather than checked, what
SERIALIZABLE does with the same shape.

The assumption was half wrong, and the half that was wrong matters. SSI
promotes a creation race to a retryable 40001 only when the loser read
the key first, because that read is what leaves the SIRead predicate
lock the conflict graph is built from. A transaction that inserts blind,
with no preceding read, gives the graph no dependency to find and
already loses with a plain 23505 at SERIALIZABLE, exactly as it would at
REPEATABLE READ.

So a blind-write upsert whose ON CONFLICT target misses the index that
can actually fire is exposed today. It is not masked by SSI and would
not be unmasked by relaxing the level, which is the opposite of what we
had recorded for those sites.

Both halves are now pinned against a real Postgres: the blind race
asserts a non-retryable 23505 under SERIALIZABLE, and the read-check
race asserts a retryable 40001 carrying a pivot reason code.
In this commit, we fix four errors in the write-path audit that an
adversarial review of the table turned up, and record the distinction
that the new conflict-shape tests establish.

The largest error was a blanket claim that every site in the table is
currently masked by SSI and so is not a live bug today. That holds for
the read-check sites, but not for the ones that insert blind. Those were
never protected by the serialization graph at all, so they already lose
with a 23505 at SERIALIZABLE. Shape B is now split into read-check and
blind, each row says which it is, and the count is restated as fourteen
sites of concern of which eleven genuinely depend on SSI.

The table was also missing a row. UpsertSession targets session_id while
oor_session_registry carries a partial unique index on the idempotency
key, so two sessions racing on one key have different session IDs, the
DO UPDATE never fires, and the loser gets a 23505. The dedup probe runs
in a separate transaction, so the write is blind. This is the same shape
as the UpsertOperation and CreatePendingBoardingSweep rows.

UpsertBinding was classified A only, but oor_vtxo_bindings declares a
second unique constraint that the conflict target does not cover, which
makes it B as well. That constraint is declared inline in the CREATE
TABLE rather than as a partial index, so the partial index inventory
does not catch it, and the inventory now says so.

Finally, the UpsertPendingIntent row pointed at the wrong read. The
status guard is same-row with the write it races, so REPEATABLE READ
still catches it; the real exposure is the anchor anti-join. The note
about the UTXO audit insert is corrected too, since that insert names
its conflict target rather than being targetless and is safe for a
different reason than the one given.
@Roasbeef

Copy link
Copy Markdown
Member Author

Ran an adversarial pass over this before asking for a bot review. Summary of what moved and what didn't.

What changed as a result. The audit table had four errors, all now fixed in two follow-up commits.

The big one: the doc claimed every site in the table is currently masked by SSI and so isn't a live bug today. That's only true for the sites that read the contested key before inserting it. SSI promotes a creation race to a retryable 40001 only when the loser holds a predicate lock from a prior read, and three of the sites insert blind, so they already lose with a bare 23505 at SERIALIZABLE. Those are reachable today and relaxing the level would take nothing away from them. Rather than argue about it I pinned both halves against a real Postgres in TestPostgresConflictShapes, and the blind-race subtest fails if you write it to the old claim. Shape B is now split into read-check and blind, and the headline count is restated as fourteen sites of concern of which eleven genuinely depend on SSI.

The table was also missing a row: UpsertSession targets session_id while oor_session_registry carries a partial unique index on the idempotency key, so two sessions racing on one key have different session IDs, the DO UPDATE never fires, and the loser gets a 23505. The dedup probe runs in its own transaction, so the write is blind. Same shape as the UpsertOperation and CreatePendingBoardingSweep rows.

UpsertBinding was classified A only, but oor_vtxo_bindings declares UNIQUE (session_id, output_index, link_kind) on top of its primary key and the conflict target doesn't cover it, so it's B as well. That constraint is inline in the CREATE TABLE rather than a partial index, which means the partial index inventory structurally can't catch it; the inventory now says so.

Finally the UpsertPendingIntent row pointed at the wrong read. The status <> 'failed' guard is same-row with the write it races, so REPEATABLE READ still catches it as an ordinary write-write conflict. The real exposure is the NOT EXISTS anti-join over pending_intent_anchors. Also corrected a claim that the UTXO audit insert is targetless; it names its target, and is safe because that target matches a total index.

What I pushed back on. The suggestion to fold the duplicated v4/v5 errors.As pairs in PgErrorDetail and PgErrorConstraint into a shared unexported pgErrorFields helper. It'd save about ten lines, but both are exported and each reads fine standing alone, and the "walks the error chain four times" argument is about an error path that only runs when a transaction has already aborted. Not worth churning reviewed code for.

What was checked and held. That READ ONLY plus REPEATABLE READ survives the pgx v5 stdlib driver and reaches the server (asserted server-side via SHOW transaction_isolation / SHOW transaction_read_only, and it matches what TxOptions.beginSQL emits); that the backend gate can't leak RR onto SQLite, including the BackendTypeUnknown case falling to SERIALIZABLE; that no ReadTxOption() closure writes, directly or transitively; and that no read path depends on cross-transaction serializability, in particular that every polling loop reopens its transaction per attempt rather than pinning a snapshot across polls. The six partial unique index predicates and the "64 write closures" count both check out verbatim.

One latent thing I did not touch, since it's pre-existing and out of scope here: the ambient-join in TransactionExecutor.ExecTx ignores txOptions by design, so a store calling ExecTx(..., WriteTxOption(), ...) from inside an actor read closure would silently join a READ ONLY transaction and fail at runtime on Postgres while succeeding on SQLite. The code comment acknowledges the design. Worth a guard eventually, but not in this PR.

@claude review this

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 2m 12s —— View job


Review: read-only Postgres at REPEATABLE READ

  • Read the functional change (db/interfaces.go)
  • Read the error-metadata change (db/sqlerrors.go)
  • Verify the two Codex findings
  • Read the isolation tests and audit
  • Post review

The core change is tight and well-scoped. txIsolationLevel gates cleanly on readOnly && BackendTypePostgres, so SQLite and BackendTypeUnknown both fall to SERIALIZABLE, and the server-side assertions in TestPostgresBeginTxIsolation (SHOW transaction_isolation / SHOW transaction_read_only) are the right way to prove the options survive the pgx stdlib driver. The READ ONLY-does-the-real-work reasoning in the comment is accurate. Two findings from Codex to adjudicate, plus a couple of small notes.


1. Missing Unwrap on ErrSQLUniqueConstraintViolation — agree (P2, worth fixing)

Codex is right, and this one matters for exactly the diagnosis commit 2 is meant to enable. ErrSQLUniqueConstraintViolation (db/sqlerrors.go:182-191) is the only mapped Postgres error type without an Unwrap method — ErrSerializationError, ErrDeadlockError, ErrSchemaError, and ErrDatabaseConnectionError all have one.

Consequence: once MapSQLError wraps a 23505 into *ErrSQLUniqueConstraintViolation, a caller holding that mapped error (i.e. the value TransactionExecutor.ExecTx returns) can no longer reach the *pgconn.PgError through the chain. PgErrorConstraint(mappedErr) / PgErrorDetail(mappedErr) both errors.As to the pgconn type, hit a dead end, and return "". That's the "which of the six partial unique indexes fired" signal — the stated reason for surfacing ConstraintName — silently going empty on the wrapped error.

Why the tests don't catch it: TestPostgresConflictShapes calls the extractors on the raw driver err (db/sqlerrors_postgres_test.go:177,223), never on mapped. The .Error() string itself works today only because ErrSQLUniqueConstraintViolation.Error() passes e.DBError (the raw error) straight into withPgDetail — so log rendering is fine, but programmatic extraction from the mapped error is not.

Fix is one method matching the siblings (value receiver, as the others use):

// Unwrap returns the wrapped error.
func (e ErrSQLUniqueConstraintViolation) Unwrap() error {
        return e.DBError
}

Worth adding an assertion on the mapped error too, e.g. require.NotEmpty(t, PgErrorConstraint(mapped)) alongside the existing raw-error check, so the wrapped path is actually pinned. Fix this →

2. Raw SQL in the isolation tests — disagree (P1 is a false positive here)

The "never write raw SQL in Go" invariant is a store/production-path rule: it exists so query paths go through sqlc for type safety and SQLite/Postgres dialect portability. It doesn't fit these two files, and the repo already treats it that way. Raw literal SQL is routine in the existing _test.go files:

  • db/tx_executor_ctx_test.go:79,95-98 — literal SELECT count(*) and dialect-branched INSERT INTO tx_test
  • db/boarding_wallet_test.go:1489-1492 — literal UPDATE boarding_intents
  • db/interfaces_test.go:139SELECT 1
  • db/migrations_test.go:250SELECT COUNT(*) FROM sqlite_master

More to the point, these probes can't use generated queries: they deliberately construct specific conflict shapes (blind vs read-check creation race, same-row write-write, an SSI pivot) at hand-picked isolation levels. That control is the whole test. Routing through sqlc would defeat the purpose. I'd leave these as-is.

3. On the pgErrorFields consolidation you pushed back on — concur

Agree with declining it. The two extractors are exported and each reads fine standalone, and the "walks the chain four times" cost only lands on an already-aborted transaction. Not worth churning reviewed code.

Minor notes (non-blocking)

  • Ambient-join ignores txOptions (db/interfaces.go:247-249): as you called out, a store doing ExecTx(..., WriteTxOption(), ...) inside an actor read closure would silently join a READ ONLY tx and fail at runtime on Postgres while passing on SQLite. Pre-existing and correctly out of scope, but with readers now genuinely READ ONLY on the server the blast radius is slightly larger than before — a one-line guard (error if txOptions disagrees with an ambient read-only tx) would be cheap insurance in a follow-up.
  • Reader retry: worth confirming for the record — a read-only REPEATABLE READ tx can't take predicate locks and can't write, so it can never raise a 40001; the absence of a reader retry loop on the relaxed path is therefore fine. The retry concern is entirely writer-side, and writers stay SERIALIZABLE. Consistent with the PR's framing.

Net: the functional change is sound and safely gated. The only thing I'd fix before merge is the missing Unwrap (#1) — small, but it's the one thing that quietly undercuts the diagnostic value commit 2 adds.
· branch db/readonly-repeatable-read

In this commit, we add the Unwrap method that
ErrSQLUniqueConstraintViolation was missing. It was the only mapped
Postgres error type without one: ErrSerializationError,
ErrDeadlockError, ErrSchemaError and ErrDatabaseConnectionError all
have it.

The omission quietly defeated the extraction added alongside it. Once
MapSQLError wraps a 23505, the mapped value is a dead end for errors.As,
so PgErrorConstraint and PgErrorDetail can no longer reach the pgconn
error underneath and both return empty. That is the normal case rather
than a corner, because ExecTx hands callers the mapped error and never
the raw driver one, and identifying which of the partial unique indexes
actually fired is the entire reason the constraint name is surfaced.

Rendering happened to keep working, which is why this was easy to miss.
The Error method passes the raw DBError into the detail renderer
directly, so a log line always looked right while programmatic
extraction returned nothing.

The existing test only ever called the extractors on the raw driver
error, so it could not see any of this. It now asserts against the
mapped error as well, which is the path callers actually take.
@Roasbeef

Copy link
Copy Markdown
Member Author

Thanks, the Unwrap finding is a good catch and it's fixed in 6b1e50d.

You're right that it's exactly the thing that undercut commit 2. I verified it the way you'd want: the mapped error really was a dead end for errors.As, so PgErrorConstraint(mapped) and PgErrorDetail(mapped) both returned empty while the rendered log line looked perfectly fine, which is why nothing noticed. ErrSQLUniqueConstraintViolation was indeed the only one of the five mapped types without an Unwrap.

The test gap you identified was the real problem, so I fixed that too rather than just the method. TestPostgresConflictShapes now asserts PgErrorConstraint(mapped) and PgErrorDetail(mapped) alongside the existing raw-error checks. I confirmed the assertion genuinely discriminates by disabling the new Unwrap and re-running: it fails on Should NOT be empty at the mapped-constraint assertion, and passes with the method restored. Extraction from the mapped error is the path that actually gets exercised downstream of ExecTx, so that's the one worth pinning.

On #2, agreed, and thanks for pulling the precedents. The rule is about production query paths going through sqlc for type safety and dialect portability, and these probes are the opposite case: they exist to construct specific conflict shapes at hand-picked isolation levels, which is precisely the control that generated queries would take away. Leaving them.

On the ambient-join note, agreed that the blast radius is marginally larger now that readers are genuinely READ ONLY server-side rather than nominally so. Still out of scope here, but your framing of the guard as cheap insurance is right and I'll carry it into the follow-up rather than let it evaporate.

On reader retry, that's the right reading and worth having on the record: a read-only REPEATABLE READ transaction takes no predicate locks and cannot write, so there's no way for it to raise a 40001, and the absence of a retry loop on the relaxed path costs nothing. The retry gap in the audit is entirely writer-side, and writers stay SERIALIZABLE in this PR.

@Roasbeef
Roasbeef merged commit 93a2cdd into main Jul 29, 2026
35 of 36 checks passed
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