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)) } }) } }