-
Notifications
You must be signed in to change notification settings - Fork 9
db: run read-only Postgres transactions at REPEATABLE READ #1057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8c397a7
5451fd6
0075c21
7c8ad3b
53a95e2
6b1e50d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| //go:build test_postgres | ||
|
|
||
| package db | ||
|
|
||
| import ( | ||
| "database/sql" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // txSessionState reads back the isolation level and read-only access mode that | ||
| // Postgres actually applied to the given transaction. Asserting on the server's | ||
| // own view is the only way to prove that the requested sql.TxOptions survived | ||
| // the trip through the pgx stdlib driver and into the BEGIN statement. | ||
| func txSessionState(t *testing.T, tx *sql.Tx) (string, string) { | ||
| t.Helper() | ||
|
|
||
| var isolation string | ||
| err := tx.QueryRow("SHOW transaction_isolation").Scan(&isolation) | ||
| require.NoError(t, err) | ||
|
|
||
| var readOnly string | ||
| err = tx.QueryRow("SHOW transaction_read_only").Scan(&readOnly) | ||
| require.NoError(t, err) | ||
|
|
||
| return isolation, readOnly | ||
| } | ||
|
|
||
| // TestPostgresBeginTxIsolation asserts that read-only transactions against | ||
| // Postgres are opened as a read-only REPEATABLE READ tx, while writers stay | ||
| // SERIALIZABLE. The read-only flag matters as much as the level, because | ||
| // Postgres only skips SIRead predicate lock acquisition for a transaction that | ||
| // is genuinely declared read only. | ||
| // | ||
| // The subtests deliberately share one store. Each store costs a docker | ||
| // container, and the fixture derives its port from a docker port binding that | ||
| // is not always populated under load. | ||
| func TestPostgresBeginTxIsolation(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| ctx := t.Context() | ||
| store := NewTestPostgresDB(t) | ||
|
|
||
| t.Run("read-only", func(t *testing.T) { | ||
| tx, err := store.BeginTx(ctx, ReadTxOption()) | ||
| require.NoError(t, err) | ||
| defer func() { | ||
| require.NoError(t, tx.Rollback()) | ||
| }() | ||
|
|
||
| isolation, readOnly := txSessionState(t, tx) | ||
| require.Equal(t, "repeatable read", isolation) | ||
| require.Equal(t, "on", readOnly) | ||
| }) | ||
|
|
||
| t.Run("read-write", func(t *testing.T) { | ||
| tx, err := store.BeginTx(ctx, WriteTxOption()) | ||
| require.NoError(t, err) | ||
| defer func() { | ||
| require.NoError(t, tx.Rollback()) | ||
| }() | ||
|
|
||
| isolation, readOnly := txSessionState(t, tx) | ||
| require.Equal(t, "serializable", isolation) | ||
| require.Equal(t, "off", readOnly) | ||
| }) | ||
|
|
||
| // The READ ONLY access mode is enforced by the server rather than being | ||
| // advisory. This is the property that justifies the audit assumption | ||
| // that every ReadTxOption call site is truly read-only: if one of them | ||
| // ever starts writing, it fails loudly instead of silently relying on | ||
| // snapshot isolation. | ||
| t.Run("read-only rejects writes", func(t *testing.T) { | ||
| tx, err := store.BeginTx(ctx, ReadTxOption()) | ||
| require.NoError(t, err) | ||
| defer func() { | ||
| require.NoError(t, tx.Rollback()) | ||
| }() | ||
|
|
||
| _, err = tx.ExecContext( | ||
| ctx, "INSERT INTO chain_info (id, chain_name, "+ | ||
| "genesis_hash) VALUES (2, 'nope', '\\x01')", | ||
| ) | ||
| require.Error(t, err) | ||
| require.ErrorContains(t, err, "read-only transaction") | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -111,14 +111,95 @@ func classifyPostgresError(code string, dbErr error) error { | |
| } | ||
| } | ||
|
|
||
| // PgErrorDetail extracts the Detail field of an underlying Postgres error, if | ||
| // there is one. The Error method of pgconn.PgError renders only the severity, | ||
| // the message and the SQLSTATE code, so the Detail is otherwise dropped on the | ||
| // floor before it ever reaches a log line. | ||
| // | ||
| // That 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, whereas an ordinary | ||
| // write-write conflict on the same row carries none. Once read-only | ||
| // transactions stop taking 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. For a 23505 the detail names the constraint and the | ||
| // conflicting key values, which is what identifies a lost creation race. | ||
| func PgErrorDetail(err error) string { | ||
| var pgErrV4 *pgconnv4.PgError | ||
| if errors.As(err, &pgErrV4) { | ||
|
Comment on lines
+127
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a caller passes the error returned by Useful? React with 👍 / 👎. |
||
| return pgErrV4.Detail | ||
| } | ||
|
|
||
| var pgErrV5 *pgconnv5.PgError | ||
| if errors.As(err, &pgErrV5) { | ||
| return pgErrV5.Detail | ||
| } | ||
|
|
||
| return "" | ||
| } | ||
|
|
||
| // PgErrorConstraint extracts the name of the constraint that a Postgres error | ||
| // was raised against, if there is one. The schema carries six partial unique | ||
| // indexes, and without the constraint name a 23505 raised by any of them looks | ||
| // exactly like a 23505 raised by the table's primary key. | ||
| func PgErrorConstraint(err error) string { | ||
| var pgErrV4 *pgconnv4.PgError | ||
| if errors.As(err, &pgErrV4) { | ||
| return pgErrV4.ConstraintName | ||
| } | ||
|
|
||
| var pgErrV5 *pgconnv5.PgError | ||
| if errors.As(err, &pgErrV5) { | ||
| return pgErrV5.ConstraintName | ||
| } | ||
|
|
||
| return "" | ||
| } | ||
|
|
||
| // withPgDetail renders a database error together with the Postgres constraint | ||
| // name and detail when they are available, and falls back to the plain error | ||
| // otherwise. | ||
| func withPgDetail(err error) string { | ||
| constraint := PgErrorConstraint(err) | ||
| detail := PgErrorDetail(err) | ||
|
|
||
| switch { | ||
| case constraint != "" && detail != "": | ||
| return fmt.Sprintf("%v (constraint: %s, detail: %s)", err, | ||
| constraint, detail) | ||
|
|
||
| case constraint != "": | ||
| return fmt.Sprintf("%v (constraint: %s)", err, constraint) | ||
|
|
||
| case detail != "": | ||
| return fmt.Sprintf("%v (detail: %s)", err, detail) | ||
|
|
||
| default: | ||
| return err.Error() | ||
| } | ||
| } | ||
|
|
||
| // ErrSQLUniqueConstraintViolation is an error type which represents a database | ||
| // agnostic SQL unique constraint violation. | ||
| type ErrSQLUniqueConstraintViolation struct { | ||
| DBError error | ||
| } | ||
|
|
||
| func (e ErrSQLUniqueConstraintViolation) Error() string { | ||
| return fmt.Sprintf("sql unique constraint violation: %v", e.DBError) | ||
| return fmt.Sprintf("sql unique constraint violation: %v", | ||
| withPgDetail(e.DBError)) | ||
| } | ||
|
|
||
| // Unwrap returns the wrapped error. | ||
| // | ||
| // Without this, the mapped error is a dead end for errors.As, and the | ||
| // PgErrorConstraint and PgErrorDetail extractors return empty for every caller | ||
| // that holds the mapped error rather than the raw driver one. That is the | ||
| // normal case, since ExecTx returns the mapped error, and identifying which of | ||
| // the partial unique indexes actually fired is the whole point of surfacing | ||
| // the constraint name. | ||
| func (e ErrSQLUniqueConstraintViolation) Unwrap() error { | ||
| return e.DBError | ||
| } | ||
|
|
||
| // ErrSerializationError is an error type which represents a database agnostic | ||
|
|
@@ -135,7 +216,7 @@ func (e ErrSerializationError) Unwrap() error { | |
|
|
||
| // Error returns the error message. | ||
| func (e ErrSerializationError) Error() string { | ||
| return e.DBError.Error() | ||
| return withPgDetail(e.DBError) | ||
| } | ||
|
|
||
| // ErrDeadlockError is an error type which represents a database agnostic error | ||
|
|
@@ -151,7 +232,22 @@ func (e ErrDeadlockError) Unwrap() error { | |
|
|
||
| // Error returns the error message. | ||
| func (e ErrDeadlockError) Error() string { | ||
| return e.DBError.Error() | ||
| return withPgDetail(e.DBError) | ||
| } | ||
|
|
||
| // IsUniqueConstraintViolation returns true if the given error is a unique | ||
| // constraint violation. | ||
| // | ||
| // This is deliberately not part of IsSerializationOrDeadlockError. A unique | ||
| // violation is not safe to retry blindly, because a retry of a plain insert | ||
| // that lost a creation race just loses it again. Callers that can lose such a | ||
| // race need to either rephrase the insert as a no-op upsert or translate the | ||
| // violation into a domain level "already exists", which is why the classifier | ||
| // is exposed separately. | ||
| func IsUniqueConstraintViolation(err error) bool { | ||
| var uniqueErr *ErrSQLUniqueConstraintViolation | ||
|
|
||
| return errors.As(err, &uniqueErr) | ||
| } | ||
|
|
||
| // IsSerializationError returns true if the given error is a serialization | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test adds a literal
INSERTthroughExecContext, anddb/sqlerrors_postgres_test.gosimilarly adds literalSELECT,INSERT, andUPDATEstatements. 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 👍 / 👎.