171 lines
7.4 KiB
Go
171 lines
7.4 KiB
Go
package pgstore
|
|
|
|
import (
|
|
"errors"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// The reading transaction is ONE snapshot and it cannot write. Both halves are asserted against
|
|
// Postgres rather than against the options struct, because what the options are FOR is behaviour: a
|
|
// test that read the struct back would pass under any value it happened to find there.
|
|
//
|
|
// Why it matters is PD-163, and the price is in the comment over ListBooks: the page and the scope's
|
|
// revision are two statements, READ COMMITTED takes a fresh snapshot per statement, and a revision
|
|
// newer than the rows it labels is one a client treats as already seen — "every frame in that window
|
|
// was lost for good". Six reading handles stand on this, and until now nothing failed when it was
|
|
// taken away: dropping both options passed the whole battery (reproduced by landing the mutation,
|
|
// audit of 21.08).
|
|
//
|
|
// Mutation caught: `inReadTx` returning `s.tx(ctx, pgx.TxOptions{}, fn)`; also dropping either
|
|
// option alone.
|
|
func TestAReadingTransactionIsOneSnapshotAndRefusesToWrite(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
seedBook(t, s, ctx, "bk1", "u1", 10)
|
|
|
|
// One snapshot: a change COMMITTED by somebody else after the first statement is invisible to the
|
|
// second. Under READ COMMITTED the second read returns the new number, which is exactly the skew
|
|
// a page and its revision straddled.
|
|
var first, second int64
|
|
if err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
|
if err := tx.QueryRow(ctx, `select revision from books where id = 'bk1'`).Scan(&first); err != nil {
|
|
return err
|
|
}
|
|
// On the POOL, so it is a different transaction and it commits before the read below.
|
|
if _, err := s.pool.Exec(ctx,
|
|
`update books set revision = revision + 100 where id = 'bk1'`); err != nil {
|
|
return err
|
|
}
|
|
return tx.QueryRow(ctx, `select revision from books where id = 'bk1'`).Scan(&second)
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second != first {
|
|
t.Errorf("the second read saw revision %d after the first saw %d: this transaction takes a snapshot per statement, so a page and the revision that labels it can come from two different worlds (PD-163)",
|
|
second, first)
|
|
}
|
|
|
|
// Read-only: the guard is Postgres's, not a convention. A write that reaches a reading path is a
|
|
// bug wherever it came from, and this is what makes it fail at the statement rather than land.
|
|
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
|
_, err := tx.Exec(ctx, `update books set title = 'written by a read' where id = 'bk1'`)
|
|
return err
|
|
})
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(err, &pgErr) || pgErr.Code != "25006" {
|
|
t.Errorf("a write inside the reading transaction answered %v, want SQLSTATE 25006 read_only_sql_transaction", err)
|
|
}
|
|
}
|
|
|
|
// …and the handles that owe their clients that snapshot actually take it. The property above is a
|
|
// property of one helper; this is the property of its USE, and the two fail independently — a handle
|
|
// moved to `inTx` reads exactly as correctly in a test with no concurrent writer.
|
|
//
|
|
// Asserted over the source because there is nothing to observe at runtime: both helpers return the
|
|
// same rows, and what differs is only what a SECOND writer could do in between.
|
|
//
|
|
// Mutation caught: changing any of these to `inTx`.
|
|
func TestEveryPagedReadingHandleTakesTheReadingTransaction(t *testing.T) {
|
|
want := map[string]bool{
|
|
"ListBooks": false, "GetBook": false, "ListChapters": false,
|
|
"ListUnits": false, "ListNotes": false, "ListBank": false,
|
|
}
|
|
sources, err := filepath.Glob("*.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fset := token.NewFileSet()
|
|
for _, path := range sources {
|
|
if strings.HasSuffix(path, "_test.go") {
|
|
continue // the subject is the package's own code, not a fixture that happens to share a name
|
|
}
|
|
file, err := parser.ParseFile(fset, path, nil, 0)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", path, err)
|
|
}
|
|
for _, decl := range file.Decls {
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
if !ok || fn.Recv == nil {
|
|
continue
|
|
}
|
|
if _, named := want[fn.Name.Name]; !named {
|
|
continue
|
|
}
|
|
ast.Inspect(fn, func(n ast.Node) bool {
|
|
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "inReadTx" {
|
|
want[fn.Name.Name] = true
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
for name, ok := range want {
|
|
if !ok {
|
|
t.Errorf("%s does not read inside inReadTx: its page and the revision that labels it can then come from two snapshots (PD-163)", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An error message the engine wrote is REPAIRED before it is stored, and cut on a rune boundary.
|
|
//
|
|
// It is not defensive habit and the cost of getting it wrong is exactly the defect this pack exists
|
|
// to end. What reaches these columns is the first line of the engine's stderr, verbatim and
|
|
// unbounded, for a product whose sources are Chinese and Japanese — long and multi-byte by nature.
|
|
// Postgres refuses a `text` value that is not valid UTF-8 (SQLSTATE 22021), so a byte slice through
|
|
// the middle of a rune makes the write FAIL, and the write that fails is the one recording the
|
|
// failure: the deadline is not moved, the counter does not rise, and the item stands at the head of
|
|
// its queue for good. Arbitrary stderr need not be valid UTF-8 to start with, which is why the
|
|
// repair comes first and the cut second.
|
|
//
|
|
// Asserted against POSTGRES rather than against the helper: the rule being kept is Postgres's, and a
|
|
// test that only measured the string would pass under any encoding it happened to produce.
|
|
//
|
|
// Mutation caught: `reason[:max]` without the rune walk; dropping `strings.ToValidUTF8`.
|
|
func TestAnEnginesOwnErrorTextSurvivesBeingRecorded(t *testing.T) {
|
|
s, ctx := testDB(t)
|
|
seedUser(t, s, ctx, "u1")
|
|
seedBook(t, s, ctx, "bk1", "u1", 10)
|
|
// Long, multi-byte, and with the cut falling INSIDE a rune: 700 three-byte runes is 2100 bytes,
|
|
// and byte 1000 is not a rune start.
|
|
long := "pipeline: read mined-delta " + strings.Repeat("蛊", 700)
|
|
// …and a line that is not valid UTF-8 at all, which is what a raw stderr byte stream can be.
|
|
invalid := "engine: " + string([]byte{0xff, 0xfe}) + strings.Repeat("真", 700)
|
|
// NUL is its own case: it is VALID UTF-8, so the repair above leaves it alone, and Postgres
|
|
// refuses it anyway with the same SQLSTATE — the write that records a failure failing on it.
|
|
nul := "engine: read " + string([]byte{0x00}) + "book.yaml"
|
|
for name, reason := range map[string]string{
|
|
"a long multi-byte line": long,
|
|
"bytes that are not UTF-8": invalid,
|
|
"a NUL byte": nul,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
stored := truncateReason(reason)
|
|
if !utf8.ValidString(stored) {
|
|
t.Fatalf("the stored form is not valid UTF-8, so Postgres will refuse it (%d bytes)", len(stored))
|
|
}
|
|
// The real assertion: Postgres takes it. `read_model_error` and `reconcile_error` are the
|
|
// two columns on the path, and they are the same type — one is enough to prove the rule.
|
|
if _, err := s.pool.Exec(ctx,
|
|
`update books set read_model_error = $2 where id = $1`, "bk1", stored); err != nil {
|
|
t.Fatalf("Postgres refused the recorded reason, so the failure it describes is never counted: %v", err)
|
|
}
|
|
var back string
|
|
if err := s.pool.QueryRow(ctx,
|
|
`select read_model_error from books where id = 'bk1'`).Scan(&back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back != stored {
|
|
t.Errorf("what came back is not what went in (%d vs %d bytes)", len(back), len(stored))
|
|
}
|
|
})
|
|
}
|
|
}
|