Files
bd2/go/internal/server/accountstate/repository_test.go
T

367 lines
9.9 KiB
Go

package accountstate
import (
"bytes"
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
func openTestRepository(t *testing.T) (*Repository, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "state.db")
r, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
return r, path
}
func requireState(t *testing.T, r *Repository, name string, want []byte, wantGeneration int64) {
t.Helper()
got, generation, found, err := r.LoadContext(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if !found || !bytes.Equal(got, want) || generation != wantGeneration {
t.Fatalf("%s: got (%q, %d, %t), want (%q, %d, true)", name, got, generation, found, want, wantGeneration)
}
}
func TestTransactionAtomicityAndGeneration(t *testing.T) {
r, _ := openTestRepository(t)
ctx := context.Background()
tx, err := r.Begin(ctx)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"wallet", "deck", "progress"} {
if generation, err := tx.Save(name, []byte(name)); err != nil || generation != 1 {
t.Fatalf("save %s: generation %d, error %v", name, generation, err)
}
}
if generation, err := tx.Save("wallet", []byte("updated")); err != nil || generation != 2 {
t.Fatalf("second save: generation %d, error %v", generation, err)
}
if data, generation, found, err := tx.Load("wallet"); err != nil || !found || !bytes.Equal(data, []byte("updated")) || generation != 2 {
t.Fatalf("read own write: %q, %d, %t, %v", data, generation, found, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if _, _, _, err := tx.Load("wallet"); !errors.Is(err, ErrClosed) {
t.Fatalf("load after commit: %v", err)
}
requireState(t, r, "wallet", []byte("updated"), 2)
requireState(t, r, "deck", []byte("deck"), 1)
requireState(t, r, "progress", []byte("progress"), 1)
}
func TestRollbackAndReopen(t *testing.T) {
r, path := openTestRepository(t)
ctx := context.Background()
if _, err := r.SaveContext(ctx, "wallet", []byte("before")); err != nil {
t.Fatal(err)
}
tx, err := r.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := tx.Save("wallet", []byte("after")); err != nil {
t.Fatal(err)
}
if _, err := tx.Save("deck", []byte("new")); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("before"), 1)
if _, _, found, err := r.LoadContext(ctx, "deck"); err != nil || found {
t.Fatalf("rolled-back domain: found=%t err=%v", found, err)
}
if _, err := r.SaveContext(ctx, "wallet", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
requireState(t, reopened, "wallet", []byte("committed"), 2)
var mode string
if err := reopened.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" {
t.Fatalf("journal mode %q: %v", mode, err)
}
var synchronous int
if err := reopened.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 2 {
t.Fatalf("synchronous %d: %v", synchronous, err)
}
}
func TestConcurrentTransactionsSerialize(t *testing.T) {
r, _ := openTestRepository(t)
first, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
finished := make(chan error, 1)
go func() {
close(entered)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
tx, err := r.Begin(ctx)
if err != nil {
finished <- err
return
}
_, err = tx.Save("wallet", []byte("second"))
if err == nil {
err = tx.Commit()
} else {
_ = tx.Rollback()
}
finished <- err
}()
<-entered
select {
case err := <-finished:
t.Fatalf("second transaction finished before first released connection: %v", err)
case <-time.After(20 * time.Millisecond):
}
if _, err := first.Save("wallet", []byte("first")); err != nil {
t.Fatal(err)
}
if err := first.Commit(); err != nil {
t.Fatal(err)
}
if err := <-finished; err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("second"), 2)
}
func TestOperationRoutesStoreWritesAcrossDomains(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.Save("wallet", []byte("before")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("after")); err != nil {
t.Fatal(err)
}
if err := r.Save("deck", []byte("new")); err != nil {
t.Fatal(err)
}
if data, err := r.Load("wallet"); err != nil || !bytes.Equal(data, []byte("after")) {
t.Fatalf("read active write: %q, %v", data, err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty request rollback did not require restart")
}
if err := r.Check(); err == nil {
t.Fatal("repository accepted requests after dirty rollback")
}
if _, err := r.BeginOperation(); err == nil {
t.Fatal("began request after dirty rollback")
}
// The durable rows are rolled back even though domain memory now needs reload.
var payload []byte
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'wallet'`).Scan(&payload); err != nil || !bytes.Equal(payload, []byte("before")) {
t.Fatalf("wallet after rollback: %q, %v", payload, err)
}
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'deck'`).Scan(&payload); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("deck after rollback: %q, %v", payload, err)
}
}
func TestOperationCommitAndCleanRollback(t *testing.T) {
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
if err := r.Check(); err != nil {
t.Fatal(err)
}
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := r.Save("deck", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("committed"), 1)
requireState(t, r, "deck", []byte("committed"), 1)
}
func TestSecondRepositoryIsRejectedUntilWriterCloses(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
first, err := Open(path)
if err != nil {
t.Fatal(err)
}
if second, err := Open(path); !errors.Is(err, ErrWriterLocked) {
if second != nil {
_ = second.Close()
}
t.Fatalf("second Open error=%v, want ErrWriterLocked", err)
}
var epoch int64
if err := first.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'writer_epoch'`).Scan(&epoch); err != nil {
t.Fatal(err)
}
if epoch != first.writerEpoch {
t.Fatalf("rejected Open advanced writer epoch to %d, want %d", epoch, first.writerEpoch)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
second, err := Open(path)
if err != nil {
t.Fatalf("Open after writer Close: %v", err)
}
defer func() {
if err := second.Close(); err != nil {
t.Error(err)
}
}()
}
func TestWriterEpochStillFencesStaleRepositoryBeforeMutation(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.db.Exec(`UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'writer_epoch'`); err != nil {
t.Fatal(err)
}
if _, err := r.BeginOperation(); !errors.Is(err, ErrFenced) {
t.Fatalf("stale writer error=%v, want ErrFenced", err)
}
}
func TestSchemaVersionRejected(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.db.Exec(`UPDATE metadata SET value = '4' WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("opened unknown schema version")
}
}
func TestPopulatedDatabaseWithoutSchemaVersionRejected(t *testing.T) {
r, path := openTestRepository(t)
if err := r.Save("wallet", []byte("existing")); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`DELETE FROM metadata WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("stamped current schema onto populated unversioned database")
}
}
func TestCurrentVersionWithMissingTableRejected(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("recreated a missing table in an existing current-version database")
}
}
func TestRequireDomainsRejectsPartialAccount(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.RequireDomains("wallet"); err == nil {
t.Fatal("accepted empty database as a complete account")
}
if err := r.Save("wallet", []byte(`{}`)); err != nil {
t.Fatal(err)
}
if err := r.RequireDomains("wallet"); err != nil {
t.Fatal(err)
}
if err := r.RequireDomains("wallet", "items"); err == nil {
t.Fatal("accepted a missing account domain")
}
}
func TestInvalidNamesAndEmptyBlob(t *testing.T) {
r, _ := openTestRepository(t)
ctx := context.Background()
if _, err := r.SaveContext(ctx, "", []byte("bad")); err == nil {
t.Fatal("empty name accepted")
}
if _, err := r.SaveContext(ctx, "empty", nil); err != nil {
t.Fatal(err)
}
requireState(t, r, "empty", []byte{}, 1)
}
func BenchmarkRequestTransaction(b *testing.B) {
path := filepath.Join(b.TempDir(), "state.db")
r, err := Open(path)
if err != nil {
b.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
b.Error(err)
}
}()
ctx := context.Background()
payload := bytes.Repeat([]byte("x"), 4096)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tx, err := r.Begin(ctx)
if err != nil {
b.Fatal(err)
}
for _, name := range []string{"wallet", "deck", "progress"} {
if _, err := tx.Save(name, payload); err != nil {
b.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
b.Fatal(err)
}
}
}