Skip to content
quarry Artifact [b153333a8b]
Login

Artifact [b153333a8b]

Artifact b153333a8bd0d146ecf1260dd2ea3b00bfa12d4185739a8a60cd51cfd40d3954:


package index

import (
	"context"
	"database/sql"
	"path/filepath"
	"testing"
	"time"
)

// shrinkRecycling makes every pool opened afterwards recycle at once.
func shrinkRecycling(t *testing.T, idle, life time.Duration) {
	t.Helper()
	oi, ol := connMaxIdleTime, connMaxLifetime
	connMaxIdleTime, connMaxLifetime = idle, life
	t.Cleanup(func() { connMaxIdleTime, connMaxLifetime = oi, ol })
}

type settings struct {
	cache, temp, busy, fk int
	queryOnly             int
	journal, sync         string
	fts                   int
}

func readSettings(t *testing.T, db *sql.DB) settings {
	t.Helper()
	ctx := context.Background()
	c, err := db.Conn(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer c.Close()
	var s settings
	for q, dst := range map[string]any{
		`PRAGMA cache_size`:   &s.cache,
		`PRAGMA temp_store`:   &s.temp,
		`PRAGMA busy_timeout`: &s.busy,
		`PRAGMA foreign_keys`: &s.fk,
		`PRAGMA query_only`:   &s.queryOnly,
		`PRAGMA journal_mode`: &s.journal,
		`PRAGMA synchronous`:  &s.sync,
	} {
		if err := c.QueryRowContext(ctx, q).Scan(dst); err != nil {
			t.Fatalf("%s: %v", q, err)
		}
	}
	// fts5 is registered per connection; a connection without it
	// cannot even prepare a MATCH against the search index.
	var n int
	if err := c.QueryRowContext(ctx, `SELECT count(*) FROM search_fts WHERE search_fts MATCH 'x'`).Scan(&n); err != nil {
		t.Errorf("FAIL: fts5 MATCH on a connection: %v", err)
	}
	s.fts = 1
	return s
}

func TestPoolsCarryRecyclingPolicy(t *testing.T) {
	ix, err := Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer ix.Close()
	ctx := context.Background()
	// Hold three reader connections at once, release them: the pool
	// may keep at most one idle.
	var conns []*sql.Conn
	for range 3 {
		c, err := ix.db.Conn(ctx)
		if err != nil {
			t.Fatal(err)
		}
		conns = append(conns, c)
	}
	for _, c := range conns {
		c.Close()
	}
	if got := ix.db.Stats().Idle; got > 1 {
		t.Errorf("FAIL: reader pool keeps %d idle connections, want at most 1", got)
	}
	if got := ix.w.Stats().MaxOpenConnections; got != 1 {
		t.Errorf("FAIL: writer pool max open = %d, want 1", got)
	}
	// Non-zero lifetime and idle time show up as closures when they are tiny.
	shrinkRecycling(t, time.Nanosecond, time.Nanosecond)
	ix2, err := Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer ix2.Close()
	for name, db := range map[string]*sql.DB{"reader": ix2.db, "writer": ix2.w} {
		for range 3 {
			if _, err := db.Exec(`SELECT 1`); err != nil {
				t.Fatal(err)
			}
			time.Sleep(2 * time.Millisecond)
		}
		st := db.Stats()
		if st.MaxLifetimeClosed == 0 && st.MaxIdleTimeClosed == 0 {
			t.Errorf("FAIL: %s pool closed nothing for lifetime or idle time: policy not applied", name)
		}
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

func TestRecycledConnectionIsFullySetUp(t *testing.T) {
	ix, err := Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer ix.Close()
	pools := map[string]*sql.DB{"reader": ix.db, "writer": ix.w}
	first := map[string]settings{}
	for n, db := range pools {
		first[n] = readSettings(t, db)
	}
	if first["reader"].queryOnly != 0 || first["reader"].temp != 2 || first["writer"].temp != 1 {
		t.Errorf("FAIL: unexpected first-connection settings %+v", first)
	}
	for n, db := range pools {
		before := db.Stats().MaxLifetimeClosed
		db.SetConnMaxLifetime(time.Nanosecond)
		time.Sleep(5 * time.Millisecond)
		// Each use returns the connection expired, so the pool closes it
		// and the next call opens a brand new one.
		for range 3 {
			readSettings(t, db)
		}
		if db.Stats().MaxLifetimeClosed <= before {
			t.Errorf("FAIL: %s pool did not recycle any connection", n)
		}
		db.SetConnMaxLifetime(connMaxLifetime)
		if got := readSettings(t, db); got != first[n] {
			t.Errorf("FAIL: %s recycled connection differs: %+v, first %+v", n, got, first[n])
		}
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

func TestTransactionAcrossLifetimeBoundary(t *testing.T) {
	ix, err := Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer ix.Close()
	ix.w.SetConnMaxLifetime(time.Nanosecond)
	tx, err := ix.w.Begin()
	if err != nil {
		t.Fatal(err)
	}
	time.Sleep(5 * time.Millisecond)
	if _, err := tx.Exec(`INSERT INTO meta (key, value) VALUES ('k', 'v')`); err != nil {
		t.Errorf("FAIL: write after lifetime expiry inside a transaction: %v", err)
	}
	if _, err := tx.Exec(`INSERT INTO meta (key, value) VALUES ('k2', 'v')`); err != nil {
		t.Errorf("FAIL: second write: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Errorf("FAIL: commit: %v", err)
	}
	var n int
	if err := ix.w.QueryRow(`SELECT count(*) FROM meta WHERE key IN ('k','k2')`).Scan(&n); err != nil || n != 2 {
		t.Errorf("FAIL: rows after commit = %d, err %v", n, err)
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

func TestIdleConnectionsAreClosedAndReopened(t *testing.T) {
	shrinkRecycling(t, 20*time.Millisecond, time.Hour)
	ix, err := Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer ix.Close()
	if err := ix.db.QueryRow(`SELECT 1`).Scan(new(int)); err != nil {
		t.Fatal(err)
	}
	deadline := time.Now().Add(5 * time.Second)
	for ix.db.Stats().OpenConnections != 0 && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	if got := ix.db.Stats().OpenConnections; got != 0 {
		t.Errorf("FAIL: %d connections still open after idle time", got)
	}
	var n int
	if err := ix.db.QueryRow(`SELECT count(*) FROM search_fts WHERE search_fts MATCH 'x'`).Scan(&n); err != nil {
		t.Errorf("FAIL: query after the pool emptied: %v", err)
	}
	if !t.Failed() {
		t.Log("ok")
	}
}