From 6738fd5bb0193daf3e2b524105ff690e8bfc32f4 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Wed, 7 Feb 2024 14:43:27 +0000
Subject: [PATCH] [feature/performance] sqlite pragma optimize on close (#2596)

* wrap database drivers in order to handle error processing, hooks, etc

* remove dead code

* add code comment, remove unused blank imports
---
 internal/db/bundb/account.go        |   8 +-
 internal/db/bundb/admin.go          |  12 +-
 internal/db/bundb/application.go    |   2 +-
 internal/db/bundb/basic.go          |   2 +-
 internal/db/bundb/bundb.go          |  52 +--
 internal/db/bundb/db.go             | 578 ----------------------------
 internal/db/bundb/domain.go         |   2 +-
 internal/db/bundb/drivers.go        | 267 +++++++++++++
 internal/db/bundb/emoji.go          |   4 +-
 internal/db/bundb/headerfilter.go   |   2 +-
 internal/db/bundb/instance.go       |   2 +-
 internal/db/bundb/list.go           |   6 +-
 internal/db/bundb/marker.go         |   4 +-
 internal/db/bundb/media.go          |   4 +-
 internal/db/bundb/mention.go        |   2 +-
 internal/db/bundb/notification.go   |   2 +-
 internal/db/bundb/poll.go           |  12 +-
 internal/db/bundb/relationship.go   |  16 +-
 internal/db/bundb/report.go         |   2 +-
 internal/db/bundb/rule.go           |   2 +-
 internal/db/bundb/search.go         |   2 +-
 internal/db/bundb/session.go        |   3 +-
 internal/db/bundb/status.go         |  11 +-
 internal/db/bundb/statusbookmark.go |   2 +-
 internal/db/bundb/statusfave.go     |   2 +-
 internal/db/bundb/tag.go            |   2 +-
 internal/db/bundb/thread.go         |   2 +-
 internal/db/bundb/timeline.go       |   2 +-
 internal/db/bundb/tombstone.go      |   2 +-
 internal/db/bundb/user.go           |   2 +-
 internal/db/bundb/util.go           |  21 +
 31 files changed, 372 insertions(+), 660 deletions(-)
 delete mode 100644 internal/db/bundb/db.go
 create mode 100644 internal/db/bundb/drivers.go

diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
index 705e1b118..4b4c78726 100644
--- a/internal/db/bundb/account.go
+++ b/internal/db/bundb/account.go
@@ -37,7 +37,7 @@ import (
 )
 
 type accountDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -334,7 +334,7 @@ func (a *accountDB) PutAccount(ctx context.Context, account *gtsmodel.Account) e
 		// It is safe to run this database transaction within cache.Store
 		// as the cache does not attempt a mutex lock until AFTER hook.
 		//
-		return a.db.RunInTx(ctx, func(tx Tx) error {
+		return a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			// create links between this account and any emojis it uses
 			for _, i := range account.EmojiIDs {
 				if _, err := tx.NewInsert().Model(&gtsmodel.AccountToEmoji{
@@ -363,7 +363,7 @@ func (a *accountDB) UpdateAccount(ctx context.Context, account *gtsmodel.Account
 		// It is safe to run this database transaction within cache.Store
 		// as the cache does not attempt a mutex lock until AFTER hook.
 		//
-		return a.db.RunInTx(ctx, func(tx Tx) error {
+		return a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			// create links between this account and any emojis it uses
 			// first clear out any old emoji links
 			if _, err := tx.
@@ -411,7 +411,7 @@ func (a *accountDB) DeleteAccount(ctx context.Context, id string) error {
 		return err
 	}
 
-	return a.db.RunInTx(ctx, func(tx Tx) error {
+	return a.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		// clear out any emoji links
 		if _, err := tx.
 			NewDelete().
diff --git a/internal/db/bundb/admin.go b/internal/db/bundb/admin.go
index e189c508e..70ae68026 100644
--- a/internal/db/bundb/admin.go
+++ b/internal/db/bundb/admin.go
@@ -45,7 +45,7 @@ import (
 const rsaKeyBits = 2048
 
 type adminDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -56,7 +56,7 @@ func (a *adminDB) IsUsernameAvailable(ctx context.Context, username string) (boo
 		Column("account.id").
 		Where("? = ?", bun.Ident("account.username"), username).
 		Where("? IS NULL", bun.Ident("account.domain"))
-	return a.db.NotExists(ctx, q)
+	return notExists(ctx, q)
 }
 
 func (a *adminDB) IsEmailAvailable(ctx context.Context, email string) (bool, error) {
@@ -73,7 +73,7 @@ func (a *adminDB) IsEmailAvailable(ctx context.Context, email string) (bool, err
 		TableExpr("? AS ?", bun.Ident("email_domain_blocks"), bun.Ident("email_domain_block")).
 		Column("email_domain_block.id").
 		Where("? = ?", bun.Ident("email_domain_block.domain"), domain)
-	emailDomainBlocked, err := a.db.Exists(ctx, emailDomainBlockedQ)
+	emailDomainBlocked, err := exists(ctx, emailDomainBlockedQ)
 	if err != nil {
 		return false, err
 	}
@@ -88,7 +88,7 @@ func (a *adminDB) IsEmailAvailable(ctx context.Context, email string) (bool, err
 		Column("user.id").
 		Where("? = ?", bun.Ident("user.email"), email).
 		WhereOr("? = ?", bun.Ident("user.unconfirmed_email"), email)
-	return a.db.NotExists(ctx, q)
+	return notExists(ctx, q)
 }
 
 func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (*gtsmodel.User, error) {
@@ -229,7 +229,7 @@ func (a *adminDB) CreateInstanceAccount(ctx context.Context) error {
 		Where("? = ?", bun.Ident("account.username"), username).
 		Where("? IS NULL", bun.Ident("account.domain"))
 
-	exists, err := a.db.Exists(ctx, q)
+	exists, err := exists(ctx, q)
 	if err != nil {
 		return err
 	}
@@ -287,7 +287,7 @@ func (a *adminDB) CreateInstanceInstance(ctx context.Context) error {
 		TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance")).
 		Where("? = ?", bun.Ident("instance.domain"), host)
 
-	exists, err := a.db.Exists(ctx, q)
+	exists, err := exists(ctx, q)
 	if err != nil {
 		return err
 	}
diff --git a/internal/db/bundb/application.go b/internal/db/bundb/application.go
index 2e17a0e94..f02632793 100644
--- a/internal/db/bundb/application.go
+++ b/internal/db/bundb/application.go
@@ -26,7 +26,7 @@ import (
 )
 
 type applicationDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/basic.go b/internal/db/bundb/basic.go
index 488f59ad5..7b523f309 100644
--- a/internal/db/bundb/basic.go
+++ b/internal/db/bundb/basic.go
@@ -27,7 +27,7 @@ import (
 )
 
 type basicDB struct {
-	db *DB
+	db *bun.DB
 }
 
 func (b *basicDB) Put(ctx context.Context, i interface{}) error {
diff --git a/internal/db/bundb/bundb.go b/internal/db/bundb/bundb.go
index 048474782..4ecbec7b9 100644
--- a/internal/db/bundb/bundb.go
+++ b/internal/db/bundb/bundb.go
@@ -52,13 +52,6 @@ import (
 	"modernc.org/sqlite"
 )
 
-var registerTables = []interface{}{
-	&gtsmodel.AccountToEmoji{},
-	&gtsmodel.StatusToEmoji{},
-	&gtsmodel.StatusToTag{},
-	&gtsmodel.ThreadToStatus{},
-}
-
 // DBService satisfies the DB interface
 type DBService struct {
 	db.Account
@@ -88,12 +81,12 @@ type DBService struct {
 	db.Timeline
 	db.User
 	db.Tombstone
-	db *DB
+	db *bun.DB
 }
 
 // GetDB returns the underlying database connection pool.
 // Should only be used in testing + exceptional circumstance.
-func (dbService *DBService) DB() *DB {
+func (dbService *DBService) DB() *bun.DB {
 	return dbService.db
 }
 
@@ -129,18 +122,18 @@ func doMigration(ctx context.Context, db *bun.DB) error {
 // NewBunDBService returns a bunDB derived from the provided config, which implements the go-fed DB interface.
 // Under the hood, it uses https://github.com/uptrace/bun to create and maintain a database connection.
 func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
-	var db *DB
+	var db *bun.DB
 	var err error
 	t := strings.ToLower(config.GetDbType())
 
 	switch t {
 	case "postgres":
-		db, err = pgConn(ctx)
+		db, err = pgConn(ctx, state)
 		if err != nil {
 			return nil, err
 		}
 	case "sqlite":
-		db, err = sqliteConn(ctx)
+		db, err = sqliteConn(ctx, state)
 		if err != nil {
 			return nil, err
 		}
@@ -159,14 +152,19 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
 
 	// table registration is needed for many-to-many, see:
 	// https://bun.uptrace.dev/orm/many-to-many-relation/
-	for _, t := range registerTables {
+	for _, t := range []interface{}{
+		&gtsmodel.AccountToEmoji{},
+		&gtsmodel.StatusToEmoji{},
+		&gtsmodel.StatusToTag{},
+		&gtsmodel.ThreadToStatus{},
+	} {
 		db.RegisterModel(t)
 	}
 
 	// perform any pending database migrations: this includes
 	// the very first 'migration' on startup which just creates
 	// necessary tables
-	if err := doMigration(ctx, db.bun); err != nil {
+	if err := doMigration(ctx, db); err != nil {
 		return nil, fmt.Errorf("db migration error: %s", err)
 	}
 
@@ -284,13 +282,18 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) {
 	return ps, nil
 }
 
-func pgConn(ctx context.Context) (*DB, error) {
+func pgConn(ctx context.Context, state *state.State) (*bun.DB, error) {
 	opts, err := deriveBunDBPGOptions() //nolint:contextcheck
 	if err != nil {
-		return nil, fmt.Errorf("could not create bundb postgres options: %s", err)
+		return nil, fmt.Errorf("could not create bundb postgres options: %w", err)
 	}
 
-	sqldb := stdlib.OpenDB(*opts)
+	cfg := stdlib.RegisterConnConfig(opts)
+
+	sqldb, err := sql.Open("pgx-gts", cfg)
+	if err != nil {
+		return nil, fmt.Errorf("could not open postgres db: %w", err)
+	}
 
 	// Tune db connections for postgres, see:
 	// - https://bun.uptrace.dev/guide/running-bun-in-production.html#database-sql
@@ -299,18 +302,18 @@ func pgConn(ctx context.Context) (*DB, error) {
 	sqldb.SetMaxIdleConns(2)                  // assume default 2; if max idle is less than max open, it will be automatically adjusted
 	sqldb.SetConnMaxLifetime(5 * time.Minute) // fine to kill old connections
 
-	db := WrapDB(bun.NewDB(sqldb, pgdialect.New()))
+	db := bun.NewDB(sqldb, pgdialect.New())
 
 	// ping to check the db is there and listening
 	if err := db.PingContext(ctx); err != nil {
-		return nil, fmt.Errorf("postgres ping: %s", err)
+		return nil, fmt.Errorf("postgres ping: %w", err)
 	}
 
 	log.Info(ctx, "connected to POSTGRES database")
 	return db, nil
 }
 
-func sqliteConn(ctx context.Context) (*DB, error) {
+func sqliteConn(ctx context.Context, state *state.State) (*bun.DB, error) {
 	// validate db address has actually been set
 	address := config.GetDbAddress()
 	if address == "" {
@@ -321,7 +324,7 @@ func sqliteConn(ctx context.Context) (*DB, error) {
 	address = buildSQLiteAddress(address)
 
 	// Open new DB instance
-	sqldb, err := sql.Open("sqlite", address)
+	sqldb, err := sql.Open("sqlite-gts", address)
 	if err != nil {
 		if errWithCode, ok := err.(*sqlite.Error); ok {
 			err = errors.New(sqlite.ErrorCodeString[errWithCode.Code()])
@@ -336,15 +339,14 @@ func sqliteConn(ctx context.Context) (*DB, error) {
 	sqldb.SetMaxIdleConns(1)              // only keep max 1 idle connection around
 	sqldb.SetConnMaxLifetime(0)           // don't kill connections due to age
 
-	// Wrap Bun database conn in our own wrapper
-	db := WrapDB(bun.NewDB(sqldb, sqlitedialect.New()))
+	db := bun.NewDB(sqldb, sqlitedialect.New())
 
 	// ping to check the db is there and listening
 	if err := db.PingContext(ctx); err != nil {
 		if errWithCode, ok := err.(*sqlite.Error); ok {
 			err = errors.New(sqlite.ErrorCodeString[errWithCode.Code()])
 		}
-		return nil, fmt.Errorf("sqlite ping: %s", err)
+		return nil, fmt.Errorf("sqlite ping: %w", err)
 	}
 	log.Infof(ctx, "connected to SQLITE database with address %s", address)
 
@@ -418,7 +420,7 @@ func deriveBunDBPGOptions() (*pgx.ConnConfig, error) {
 		// parse the PEM block into the certificate
 		caCert, err := x509.ParseCertificate(caPem.Bytes)
 		if err != nil {
-			return nil, fmt.Errorf("could not parse cert at %s into x509 certificate: %s", certPath, err)
+			return nil, fmt.Errorf("could not parse cert at %s into x509 certificate: %w", certPath, err)
 		}
 
 		// we're happy, add it to the existing pool and then use this pool in our tls config
diff --git a/internal/db/bundb/db.go b/internal/db/bundb/db.go
deleted file mode 100644
index 2b19ba0c4..000000000
--- a/internal/db/bundb/db.go
+++ /dev/null
@@ -1,578 +0,0 @@
-// GoToSocial
-// Copyright (C) GoToSocial Authors admin@gotosocial.org
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-package bundb
-
-import (
-	"context"
-	"database/sql"
-	"time"
-	"unsafe"
-
-	"github.com/superseriousbusiness/gotosocial/internal/db"
-	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
-	"github.com/uptrace/bun"
-	"github.com/uptrace/bun/dialect"
-	"github.com/uptrace/bun/schema"
-)
-
-// DB wraps a bun database instance
-// to provide common per-dialect SQL error
-// conversions to common types, and retries
-// on returned busy (SQLite only).
-type DB struct {
-	// our own wrapped db type
-	// with retry backoff support.
-	// kept separate to the *bun.DB
-	// type to be passed into query
-	// builders as bun.IConn iface
-	// (this prevents double firing
-	// bun query hooks).
-	//
-	// also holds per-dialect
-	// error hook function.
-	raw rawdb
-
-	// bun DB interface we use
-	// for dialects, and improved
-	// struct marshal/unmarshaling.
-	bun *bun.DB
-}
-
-// WrapDB wraps a bun database instance in our database type.
-func WrapDB(db *bun.DB) *DB {
-	var errProc func(error) error
-	switch name := db.Dialect().Name(); name {
-	case dialect.PG:
-		errProc = processPostgresError
-	case dialect.SQLite:
-		errProc = processSQLiteError
-	default:
-		panic("unknown dialect name: " + name.String())
-	}
-	return &DB{
-		raw: rawdb{
-			errHook: errProc,
-			db:      db.DB,
-		},
-		bun: db,
-	}
-}
-
-// Dialect is a direct call-through to bun.DB.Dialect().
-func (db *DB) Dialect() schema.Dialect { return db.bun.Dialect() }
-
-// AddQueryHook is a direct call-through to bun.DB.AddQueryHook().
-func (db *DB) AddQueryHook(hook bun.QueryHook) { db.bun.AddQueryHook(hook) }
-
-// RegisterModels is a direct call-through to bun.DB.RegisterModels().
-func (db *DB) RegisterModel(models ...any) { db.bun.RegisterModel(models...) }
-
-// PingContext is a direct call-through to bun.DB.PingContext().
-func (db *DB) PingContext(ctx context.Context) error { return db.bun.PingContext(ctx) }
-
-// Close is a direct call-through to bun.DB.Close().
-func (db *DB) Close() error { return db.bun.Close() }
-
-// ExecContext wraps bun.DB.ExecContext() with retry-busy timeout and our own error processing.
-func (db *DB) ExecContext(ctx context.Context, query string, args ...any) (result sql.Result, err error) {
-	bundb := db.bun // use underlying *bun.DB interface for their query formatting
-	err = retryOnBusy(ctx, func() error {
-		result, err = bundb.ExecContext(ctx, query, args...)
-		err = db.raw.errHook(err)
-		return err
-	})
-	return
-}
-
-// QueryContext wraps bun.DB.ExecContext() with retry-busy timeout and our own error processing.
-func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (rows *sql.Rows, err error) {
-	bundb := db.bun // use underlying *bun.DB interface for their query formatting
-	err = retryOnBusy(ctx, func() error {
-		rows, err = bundb.QueryContext(ctx, query, args...)
-		err = db.raw.errHook(err)
-		return err
-	})
-	return
-}
-
-// QueryRowContext wraps bun.DB.ExecContext() with retry-busy timeout and our own error processing.
-func (db *DB) QueryRowContext(ctx context.Context, query string, args ...any) (row *sql.Row) {
-	bundb := db.bun // use underlying *bun.DB interface for their query formatting
-	_ = retryOnBusy(ctx, func() error {
-		row = bundb.QueryRowContext(ctx, query, args...)
-		if err := db.raw.errHook(row.Err()); err != nil {
-			updateRowError(row, err) // set new error
-		}
-		return row.Err()
-	})
-	return
-}
-
-// BeginTx wraps bun.DB.BeginTx() with retry-busy timeout and our own error processing.
-func (db *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (tx Tx, err error) {
-	var buntx bun.Tx // captured bun.Tx
-	bundb := db.bun  // use *bun.DB interface to return bun.Tx type
-
-	err = retryOnBusy(ctx, func() error {
-		buntx, err = bundb.BeginTx(ctx, opts)
-		err = db.raw.errHook(err)
-		return err
-	})
-
-	if err == nil {
-		// Wrap bun.Tx in our type.
-		tx = wrapTx(db, &buntx)
-	}
-
-	return
-}
-
-// RunInTx is functionally the same as bun.DB.RunInTx() but with retry-busy timeouts.
-func (db *DB) RunInTx(ctx context.Context, fn func(Tx) error) error {
-	// Attempt to start new transaction.
-	tx, err := db.BeginTx(ctx, nil)
-	if err != nil {
-		return err
-	}
-
-	var done bool
-
-	defer func() {
-		if !done {
-			// Rollback tx.
-			_ = tx.Rollback()
-		}
-	}()
-
-	// Perform supplied transaction
-	if err := fn(tx); err != nil {
-		return err
-	}
-
-	// Commit tx.
-	err = tx.Commit()
-	done = true
-	return err
-}
-
-func (db *DB) NewValues(model interface{}) *bun.ValuesQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewValuesQuery(db.bun, model).Conn(&db.raw)
-}
-
-func (db *DB) NewMerge() *bun.MergeQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewMergeQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewSelect() *bun.SelectQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewSelectQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewInsert() *bun.InsertQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewInsertQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewUpdate() *bun.UpdateQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewUpdateQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewDelete() *bun.DeleteQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewDeleteQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewRaw(query string, args ...interface{}) *bun.RawQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewRawQuery(db.bun, query, args...).Conn(&db.raw)
-}
-
-func (db *DB) NewCreateTable() *bun.CreateTableQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewCreateTableQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewDropTable() *bun.DropTableQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewDropTableQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewCreateIndex() *bun.CreateIndexQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewCreateIndexQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewDropIndex() *bun.DropIndexQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewDropIndexQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewTruncateTable() *bun.TruncateTableQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewTruncateTableQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewAddColumn() *bun.AddColumnQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewAddColumnQuery(db.bun).Conn(&db.raw)
-}
-
-func (db *DB) NewDropColumn() *bun.DropColumnQuery {
-	// note: passing in rawdb as conn iface so no double query-hook
-	// firing when passed through the bun.DB.Query___() functions.
-	return bun.NewDropColumnQuery(db.bun).Conn(&db.raw)
-}
-
-// Exists checks the results of a SelectQuery for the existence of the data in question, masking ErrNoEntries errors.
-func (db *DB) Exists(ctx context.Context, query *bun.SelectQuery) (bool, error) {
-	exists, err := query.Exists(ctx)
-	switch err {
-	case nil:
-		return exists, nil
-	case sql.ErrNoRows:
-		return false, nil
-	default:
-		return false, err
-	}
-}
-
-// NotExists checks the results of a SelectQuery for the non-existence of the data in question, masking ErrNoEntries errors.
-func (db *DB) NotExists(ctx context.Context, query *bun.SelectQuery) (bool, error) {
-	exists, err := db.Exists(ctx, query)
-	return !exists, err
-}
-
-type rawdb struct {
-	// dialect specific error
-	// processing function hook.
-	errHook func(error) error
-
-	// embedded raw
-	// db interface
-	db *sql.DB
-}
-
-// ExecContext wraps sql.DB.ExecContext() with retry-busy timeout and our own error processing.
-func (db *rawdb) ExecContext(ctx context.Context, query string, args ...any) (result sql.Result, err error) {
-	err = retryOnBusy(ctx, func() error {
-		result, err = db.db.ExecContext(ctx, query, args...)
-		err = db.errHook(err)
-		return err
-	})
-	return
-}
-
-// QueryContext wraps sql.DB.QueryContext() with retry-busy timeout and our own error processing.
-func (db *rawdb) QueryContext(ctx context.Context, query string, args ...any) (rows *sql.Rows, err error) {
-	err = retryOnBusy(ctx, func() error {
-		rows, err = db.db.QueryContext(ctx, query, args...)
-		err = db.errHook(err)
-		return err
-	})
-	return
-}
-
-// QueryRowContext wraps sql.DB.QueryRowContext() with retry-busy timeout and our own error processing.
-func (db *rawdb) QueryRowContext(ctx context.Context, query string, args ...any) (row *sql.Row) {
-	_ = retryOnBusy(ctx, func() error {
-		row = db.db.QueryRowContext(ctx, query, args...)
-		err := db.errHook(row.Err())
-		return err
-	})
-	return
-}
-
-// Tx wraps a bun transaction instance
-// to provide common per-dialect SQL error
-// conversions to common types, and retries
-// on busy commit/rollback (SQLite only).
-type Tx struct {
-	// our own wrapped Tx type
-	// kept separate to the *bun.Tx
-	// type to be passed into query
-	// builders as bun.IConn iface
-	// (this prevents double firing
-	// bun query hooks).
-	//
-	// also holds per-dialect
-	// error hook function.
-	raw rawtx
-
-	// bun Tx interface we use
-	// for dialects, and improved
-	// struct marshal/unmarshaling.
-	bun *bun.Tx
-}
-
-// wrapTx wraps a given bun.Tx in our own wrapping Tx type.
-func wrapTx(db *DB, tx *bun.Tx) Tx {
-	return Tx{
-		raw: rawtx{
-			errHook: db.raw.errHook,
-			tx:      tx.Tx,
-		},
-		bun: tx,
-	}
-}
-
-// ExecContext wraps bun.Tx.ExecContext() with our own error processing, WITHOUT retry-busy timeouts (as will be mid-transaction).
-func (tx Tx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
-	buntx := tx.bun // use underlying *bun.Tx interface for their query formatting
-	res, err := buntx.ExecContext(ctx, query, args...)
-	err = tx.raw.errHook(err)
-	return res, err
-}
-
-// QueryContext wraps bun.Tx.QueryContext() with our own error processing, WITHOUT retry-busy timeouts (as will be mid-transaction).
-func (tx Tx) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
-	buntx := tx.bun // use underlying *bun.Tx interface for their query formatting
-	rows, err := buntx.QueryContext(ctx, query, args...)
-	err = tx.raw.errHook(err)
-	return rows, err
-}
-
-// QueryRowContext wraps bun.Tx.QueryRowContext() with our own error processing, WITHOUT retry-busy timeouts (as will be mid-transaction).
-func (tx Tx) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
-	buntx := tx.bun // use underlying *bun.Tx interface for their query formatting
-	row := buntx.QueryRowContext(ctx, query, args...)
-	if err := tx.raw.errHook(row.Err()); err != nil {
-		updateRowError(row, err) // set new error
-	}
-	return row
-}
-
-// Commit wraps bun.Tx.Commit() with retry-busy timeout and our own error processing.
-func (tx Tx) Commit() (err error) {
-	buntx := tx.bun // use *bun.Tx interface
-	err = retryOnBusy(context.TODO(), func() error {
-		err = buntx.Commit()
-		err = tx.raw.errHook(err)
-		return err
-	})
-	return
-}
-
-// Rollback wraps bun.Tx.Rollback() with retry-busy timeout and our own error processing.
-func (tx Tx) Rollback() (err error) {
-	buntx := tx.bun // use *bun.Tx interface
-	err = retryOnBusy(context.TODO(), func() error {
-		err = buntx.Rollback()
-		err = tx.raw.errHook(err)
-		return err
-	})
-	return
-}
-
-// Dialect is a direct call-through to bun.DB.Dialect().
-func (tx Tx) Dialect() schema.Dialect {
-	return tx.bun.Dialect()
-}
-
-func (tx Tx) NewValues(model interface{}) *bun.ValuesQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewValues(model).Conn(&tx.raw)
-}
-
-func (tx Tx) NewMerge() *bun.MergeQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewMerge().Conn(&tx.raw)
-}
-
-func (tx Tx) NewSelect() *bun.SelectQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewSelect().Conn(&tx.raw)
-}
-
-func (tx Tx) NewInsert() *bun.InsertQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewInsert().Conn(&tx.raw)
-}
-
-func (tx Tx) NewUpdate() *bun.UpdateQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewUpdate().Conn(&tx.raw)
-}
-
-func (tx Tx) NewDelete() *bun.DeleteQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewDelete().Conn(&tx.raw)
-}
-
-func (tx Tx) NewRaw(query string, args ...interface{}) *bun.RawQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewRaw(query, args...).Conn(&tx.raw)
-}
-
-func (tx Tx) NewCreateTable() *bun.CreateTableQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewCreateTable().Conn(&tx.raw)
-}
-
-func (tx Tx) NewDropTable() *bun.DropTableQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewDropTable().Conn(&tx.raw)
-}
-
-func (tx Tx) NewCreateIndex() *bun.CreateIndexQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewCreateIndex().Conn(&tx.raw)
-}
-
-func (tx Tx) NewDropIndex() *bun.DropIndexQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewDropIndex().Conn(&tx.raw)
-}
-
-func (tx Tx) NewTruncateTable() *bun.TruncateTableQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewTruncateTable().Conn(&tx.raw)
-}
-
-func (tx Tx) NewAddColumn() *bun.AddColumnQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewAddColumn().Conn(&tx.raw)
-}
-
-func (tx Tx) NewDropColumn() *bun.DropColumnQuery {
-	// note: passing in rawtx as conn iface so no double query-hook
-	// firing when passed through the bun.Tx.Query___() functions.
-	return tx.bun.NewDropColumn().Conn(&tx.raw)
-}
-
-type rawtx struct {
-	// dialect specific error
-	// processing function hook.
-	errHook func(error) error
-
-	// embedded raw
-	// tx interface
-	tx *sql.Tx
-}
-
-// ExecContext wraps sql.Tx.ExecContext() with our own error processing, WITHOUT retry-busy timeouts (as will be mid-transaction).
-func (tx *rawtx) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
-	res, err := tx.tx.ExecContext(ctx, query, args...)
-	err = tx.errHook(err)
-	return res, err
-}
-
-// QueryContext wraps sql.Tx.QueryContext() with our own error processing, WITHOUT retry-busy timeouts (as will be mid-transaction).
-func (tx *rawtx) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
-	rows, err := tx.tx.QueryContext(ctx, query, args...)
-	err = tx.errHook(err)
-	return rows, err
-}
-
-// QueryRowContext wraps sql.Tx.QueryRowContext() with our own error processing, WITHOUT retry-busy timeouts (as will be mid-transaction).
-func (tx *rawtx) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
-	row := tx.tx.QueryRowContext(ctx, query, args...)
-	if err := tx.errHook(row.Err()); err != nil {
-		updateRowError(row, err) // set new error
-	}
-	return row
-}
-
-// updateRowError updates an sql.Row's internal error field using the unsafe package.
-func updateRowError(sqlrow *sql.Row, err error) {
-	type row struct {
-		err  error
-		rows *sql.Rows
-	}
-
-	// compile-time check to ensure sql.Row not changed.
-	if unsafe.Sizeof(row{}) != unsafe.Sizeof(sql.Row{}) {
-		panic("sql.Row has changed definition")
-	}
-
-	// this code is awful and i must be shamed for this.
-	(*row)(unsafe.Pointer(sqlrow)).err = err
-}
-
-// retryOnBusy will retry given function on returned 'errBusy'.
-func retryOnBusy(ctx context.Context, fn func() error) error {
-	var backoff time.Duration
-
-	for i := 0; ; i++ {
-		// Perform func.
-		err := fn()
-
-		if err != errBusy {
-			// May be nil, or may be
-			// some other error, either
-			// way return here.
-			return err
-		}
-
-		// backoff according to a multiplier of 2ms * 2^2n,
-		// up to a maximum possible backoff time of 5 minutes.
-		//
-		// this works out as the following:
-		// 4ms
-		// 16ms
-		// 64ms
-		// 256ms
-		// 1.024s
-		// 4.096s
-		// 16.384s
-		// 1m5.536s
-		// 4m22.144s
-		backoff = 2 * time.Millisecond * (1 << (2*i + 1))
-		if backoff >= 5*time.Minute {
-			break
-		}
-
-		select {
-		// Context cancelled.
-		case <-ctx.Done():
-
-		// Backoff for some time.
-		case <-time.After(backoff):
-		}
-	}
-
-	return gtserror.Newf("%w (waited > %s)", db.ErrBusyTimeout, backoff)
-}
diff --git a/internal/db/bundb/domain.go b/internal/db/bundb/domain.go
index 2398e52c2..1254d79c8 100644
--- a/internal/db/bundb/domain.go
+++ b/internal/db/bundb/domain.go
@@ -31,7 +31,7 @@ import (
 )
 
 type domainDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/drivers.go b/internal/db/bundb/drivers.go
new file mode 100644
index 000000000..14d84e6fa
--- /dev/null
+++ b/internal/db/bundb/drivers.go
@@ -0,0 +1,267 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+package bundb
+
+import (
+	"context"
+	"database/sql"
+	"database/sql/driver"
+	"time"
+	_ "unsafe" // linkname shenanigans
+
+	pgx "github.com/jackc/pgx/v5/stdlib"
+	"github.com/superseriousbusiness/gotosocial/internal/db"
+	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
+	"modernc.org/sqlite"
+)
+
+var (
+	// global SQL driver instances.
+	postgresDriver = pgx.GetDefaultDriver()
+	sqliteDriver   = getSQLiteDriver()
+)
+
+func init() {
+	sql.Register("pgx-gts", &PostgreSQLDriver{})
+	sql.Register("sqlite-gts", &SQLiteDriver{})
+}
+
+//go:linkname getSQLiteDriver modernc.org/sqlite.newDriver
+func getSQLiteDriver() *sqlite.Driver
+
+// PostgreSQLDriver is our own wrapper around the
+// pgx/stdlib.Driver{} type in order to wrap further
+// SQL driver types with our own err processing.
+type PostgreSQLDriver struct{}
+
+func (d *PostgreSQLDriver) Open(name string) (driver.Conn, error) {
+	c, err := postgresDriver.Open(name)
+	if err != nil {
+		return nil, err
+	}
+	return &PostgreSQLConn{conn: c.(conn)}, nil
+}
+
+type PostgreSQLConn struct{ conn }
+
+func (c *PostgreSQLConn) Begin() (driver.Tx, error) {
+	return c.BeginTx(context.Background(), driver.TxOptions{})
+}
+
+func (c *PostgreSQLConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
+	tx, err := c.conn.BeginTx(ctx, opts)
+	err = processPostgresError(err)
+	return tx, err
+}
+
+func (c *PostgreSQLConn) Prepare(query string) (driver.Stmt, error) {
+	return c.PrepareContext(context.Background(), query)
+}
+
+func (c *PostgreSQLConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) {
+	stmt, err := c.conn.PrepareContext(ctx, query)
+	err = processPostgresError(err)
+	return stmt, err
+}
+
+func (c *PostgreSQLConn) Exec(query string, args []driver.NamedValue) (driver.Result, error) {
+	return c.ExecContext(context.Background(), query, args)
+}
+
+func (c *PostgreSQLConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
+	result, err := c.conn.ExecContext(ctx, query, args)
+	err = processPostgresError(err)
+	return result, err
+}
+
+func (c *PostgreSQLConn) Query(query string, args []driver.NamedValue) (driver.Rows, error) {
+	return c.QueryContext(context.Background(), query, args)
+}
+
+func (c *PostgreSQLConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
+	rows, err := c.conn.QueryContext(ctx, query, args)
+	err = processPostgresError(err)
+	return rows, err
+}
+
+func (c *PostgreSQLConn) Close() error {
+	return c.conn.Close()
+}
+
+type PostgreSQLTx struct{ driver.Tx }
+
+func (tx *PostgreSQLTx) Commit() error {
+	err := tx.Tx.Commit()
+	return processPostgresError(err)
+}
+
+func (tx *PostgreSQLTx) Rollback() error {
+	err := tx.Tx.Rollback()
+	return processPostgresError(err)
+}
+
+// SQLiteDriver is our own wrapper around the
+// sqlite.Driver{} type in order to wrap further
+// SQL driver types with our own functionality,
+// e.g. hooks, retries and err processing.
+type SQLiteDriver struct{}
+
+func (d *SQLiteDriver) Open(name string) (driver.Conn, error) {
+	c, err := sqliteDriver.Open(name)
+	if err != nil {
+		return nil, err
+	}
+	return &SQLiteConn{conn: c.(conn)}, nil
+}
+
+type SQLiteConn struct{ conn }
+
+func (c *SQLiteConn) Begin() (driver.Tx, error) {
+	return c.BeginTx(context.Background(), driver.TxOptions{})
+}
+
+func (c *SQLiteConn) BeginTx(ctx context.Context, opts driver.TxOptions) (tx driver.Tx, err error) {
+	err = retryOnBusy(ctx, func() error {
+		tx, err = c.conn.BeginTx(ctx, opts)
+		err = processSQLiteError(err)
+		return err
+	})
+	return &SQLiteTx{Context: ctx, Tx: tx}, nil
+}
+
+func (c *SQLiteConn) Prepare(query string) (driver.Stmt, error) {
+	return c.PrepareContext(context.Background(), query)
+}
+
+func (c *SQLiteConn) PrepareContext(ctx context.Context, query string) (stmt driver.Stmt, err error) {
+	err = retryOnBusy(ctx, func() error {
+		stmt, err = c.conn.PrepareContext(ctx, query)
+		err = processSQLiteError(err)
+		return err
+	})
+	return
+}
+
+func (c *SQLiteConn) Exec(query string, args []driver.NamedValue) (driver.Result, error) {
+	return c.ExecContext(context.Background(), query, args)
+}
+
+func (c *SQLiteConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (result driver.Result, err error) {
+	err = retryOnBusy(ctx, func() error {
+		result, err = c.conn.ExecContext(ctx, query, args)
+		err = processSQLiteError(err)
+		return err
+	})
+	return
+}
+
+func (c *SQLiteConn) Query(query string, args []driver.NamedValue) (driver.Rows, error) {
+	return c.QueryContext(context.Background(), query, args)
+}
+
+func (c *SQLiteConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (rows driver.Rows, err error) {
+	err = retryOnBusy(ctx, func() error {
+		rows, err = c.conn.QueryContext(ctx, query, args)
+		err = processSQLiteError(err)
+		return err
+	})
+	return
+}
+
+func (c *SQLiteConn) Close() error {
+	// see: https://www.sqlite.org/pragma.html#pragma_optimize
+	const onClose = "PRAGMA analysis_limit=1000; PRAGMA optimize;"
+	_, _ = c.conn.ExecContext(context.Background(), onClose, nil)
+	return c.conn.Close()
+}
+
+type SQLiteTx struct {
+	context.Context
+	driver.Tx
+}
+
+func (tx *SQLiteTx) Commit() (err error) {
+	err = retryOnBusy(tx.Context, func() error {
+		err = tx.Tx.Commit()
+		err = processSQLiteError(err)
+		return err
+	})
+	return
+}
+
+func (tx *SQLiteTx) Rollback() (err error) {
+	err = retryOnBusy(tx.Context, func() error {
+		err = tx.Tx.Rollback()
+		err = processSQLiteError(err)
+		return err
+	})
+	return
+}
+
+type conn interface {
+	driver.Conn
+	driver.ConnPrepareContext
+	driver.ExecerContext
+	driver.QueryerContext
+	driver.ConnBeginTx
+}
+
+// retryOnBusy will retry given function on returned 'errBusy'.
+func retryOnBusy(ctx context.Context, fn func() error) error {
+	var backoff time.Duration
+
+	for i := 0; ; i++ {
+		// Perform func.
+		err := fn()
+
+		if err != errBusy {
+			// May be nil, or may be
+			// some other error, either
+			// way return here.
+			return err
+		}
+
+		// backoff according to a multiplier of 2ms * 2^2n,
+		// up to a maximum possible backoff time of 5 minutes.
+		//
+		// this works out as the following:
+		// 4ms
+		// 16ms
+		// 64ms
+		// 256ms
+		// 1.024s
+		// 4.096s
+		// 16.384s
+		// 1m5.536s
+		// 4m22.144s
+		backoff = 2 * time.Millisecond * (1 << (2*i + 1))
+		if backoff >= 5*time.Minute {
+			break
+		}
+
+		select {
+		// Context cancelled.
+		case <-ctx.Done():
+
+		// Backoff for some time.
+		case <-time.After(backoff):
+		}
+	}
+
+	return gtserror.Newf("%w (waited > %s)", db.ErrBusyTimeout, backoff)
+}
diff --git a/internal/db/bundb/emoji.go b/internal/db/bundb/emoji.go
index 608cb6417..69d33eede 100644
--- a/internal/db/bundb/emoji.go
+++ b/internal/db/bundb/emoji.go
@@ -38,7 +38,7 @@ import (
 )
 
 type emojiDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -109,7 +109,7 @@ func (e *emojiDB) DeleteEmojiByID(ctx context.Context, id string) error {
 		return err
 	}
 
-	return e.db.RunInTx(ctx, func(tx Tx) error {
+	return e.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		// Delete relational links between this emoji
 		// and any statuses using it, returning the
 		// status IDs so we can later update them.
diff --git a/internal/db/bundb/headerfilter.go b/internal/db/bundb/headerfilter.go
index 087b65c82..b02d9249e 100644
--- a/internal/db/bundb/headerfilter.go
+++ b/internal/db/bundb/headerfilter.go
@@ -29,7 +29,7 @@ import (
 )
 
 type headerFilterDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go
index d506e0a31..5f96f9a26 100644
--- a/internal/db/bundb/instance.go
+++ b/internal/db/bundb/instance.go
@@ -34,7 +34,7 @@ import (
 )
 
 type instanceDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/list.go b/internal/db/bundb/list.go
index 5f95d3c24..fb97c8fe7 100644
--- a/internal/db/bundb/list.go
+++ b/internal/db/bundb/list.go
@@ -35,7 +35,7 @@ import (
 )
 
 type listDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -198,7 +198,7 @@ func (l *listDB) DeleteListByID(ctx context.Context, id string) error {
 		}
 	}()
 
-	return l.db.RunInTx(ctx, func(tx Tx) error {
+	return l.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		// Delete all entries attached to list.
 		if _, err := tx.NewDelete().
 			Table("list_entries").
@@ -515,7 +515,7 @@ func (l *listDB) PutListEntries(ctx context.Context, entries []*gtsmodel.ListEnt
 	}()
 
 	// Finally, insert each list entry into the database.
-	return l.db.RunInTx(ctx, func(tx Tx) error {
+	return l.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		for _, entry := range entries {
 			entry := entry // rescope
 			if err := l.state.Caches.GTS.ListEntry.Store(entry, func() error {
diff --git a/internal/db/bundb/marker.go b/internal/db/bundb/marker.go
index b1dedb4f1..0ae50f269 100644
--- a/internal/db/bundb/marker.go
+++ b/internal/db/bundb/marker.go
@@ -30,7 +30,7 @@ import (
 )
 
 type markerDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -85,7 +85,7 @@ func (m *markerDB) UpdateMarker(ctx context.Context, marker *gtsmodel.Marker) er
 		// Optimistic concurrency control: start a transaction, try to update a row with a previously retrieved version.
 		// If the update in the transaction fails to actually change anything, another update happened concurrently, and
 		// this update should be retried by the caller, which in this case involves sending HTTP 409 to the API client.
-		return m.db.RunInTx(ctx, func(tx Tx) error {
+		return m.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			result, err := tx.NewUpdate().
 				Model(marker).
 				WherePK().
diff --git a/internal/db/bundb/media.go b/internal/db/bundb/media.go
index ced38a588..99ef30d22 100644
--- a/internal/db/bundb/media.go
+++ b/internal/db/bundb/media.go
@@ -34,7 +34,7 @@ import (
 )
 
 type mediaDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -151,7 +151,7 @@ func (m *mediaDB) DeleteAttachment(ctx context.Context, id string) error {
 	defer m.state.Caches.GTS.Media.Invalidate("ID", id)
 
 	// Delete media attachment in new transaction.
-	err = m.db.RunInTx(ctx, func(tx Tx) error {
+	err = m.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		if media.AccountID != "" {
 			var account gtsmodel.Account
 
diff --git a/internal/db/bundb/mention.go b/internal/db/bundb/mention.go
index b069423bb..156469544 100644
--- a/internal/db/bundb/mention.go
+++ b/internal/db/bundb/mention.go
@@ -33,7 +33,7 @@ import (
 )
 
 type mentionDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/notification.go b/internal/db/bundb/notification.go
index ed34222fb..3f3d5fbd6 100644
--- a/internal/db/bundb/notification.go
+++ b/internal/db/bundb/notification.go
@@ -34,7 +34,7 @@ import (
 )
 
 type notificationDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/poll.go b/internal/db/bundb/poll.go
index 0dfb15621..37a1f26ab 100644
--- a/internal/db/bundb/poll.go
+++ b/internal/db/bundb/poll.go
@@ -34,7 +34,7 @@ import (
 )
 
 type pollDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -154,7 +154,7 @@ func (p *pollDB) UpdatePoll(ctx context.Context, poll *gtsmodel.Poll, cols ...st
 	poll.CheckVotes()
 
 	return p.state.Caches.GTS.Poll.Store(poll, func() error {
-		return p.db.RunInTx(ctx, func(tx Tx) error {
+		return p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			// Update the status' "updated_at" field.
 			if _, err := tx.NewUpdate().
 				Table("statuses").
@@ -362,7 +362,7 @@ func (p *pollDB) PopulatePollVote(ctx context.Context, vote *gtsmodel.PollVote)
 
 func (p *pollDB) PutPollVote(ctx context.Context, vote *gtsmodel.PollVote) error {
 	return p.state.Caches.GTS.PollVote.Store(vote, func() error {
-		return p.db.RunInTx(ctx, func(tx Tx) error {
+		return p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			// Try insert vote into database.
 			if _, err := tx.NewInsert().
 				Model(vote).
@@ -398,7 +398,7 @@ func (p *pollDB) PutPollVote(ctx context.Context, vote *gtsmodel.PollVote) error
 }
 
 func (p *pollDB) DeletePollVotes(ctx context.Context, pollID string) error {
-	err := p.db.RunInTx(ctx, func(tx Tx) error {
+	err := p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		// Delete all votes in poll.
 		res, err := tx.NewDelete().
 			Table("poll_votes").
@@ -469,7 +469,7 @@ func (p *pollDB) DeletePollVotes(ctx context.Context, pollID string) error {
 }
 
 func (p *pollDB) DeletePollVoteBy(ctx context.Context, pollID string, accountID string) error {
-	err := p.db.RunInTx(ctx, func(tx Tx) error {
+	err := p.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		// Slice should only ever be of length
 		// 0 or 1; it's a slice of slices only
 		// because we can't LIMIT deletes to 1.
@@ -569,7 +569,7 @@ func (p *pollDB) DeletePollVotesByAccountID(ctx context.Context, accountID strin
 }
 
 // newSelectPollVotes returns a new select query for all rows in the poll_votes table with poll_id = pollID.
-func newSelectPollVotes(db *DB, pollID string) *bun.SelectQuery {
+func newSelectPollVotes(db *bun.DB, pollID string) *bun.SelectQuery {
 	return db.NewSelect().
 		TableExpr("?", bun.Ident("poll_votes")).
 		ColumnExpr("?", bun.Ident("id")).
diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go
index 4c50862a1..71ae37545 100644
--- a/internal/db/bundb/relationship.go
+++ b/internal/db/bundb/relationship.go
@@ -31,7 +31,7 @@ import (
 )
 
 type relationshipDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -299,7 +299,7 @@ func (r *relationshipDB) getAccountBlockIDs(ctx context.Context, accountID strin
 }
 
 // newSelectFollowRequests returns a new select query for all rows in the follow_requests table with target_account_id = accountID.
-func newSelectFollowRequests(db *DB, accountID string) *bun.SelectQuery {
+func newSelectFollowRequests(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		TableExpr("?", bun.Ident("follow_requests")).
 		ColumnExpr("?", bun.Ident("id")).
@@ -308,7 +308,7 @@ func newSelectFollowRequests(db *DB, accountID string) *bun.SelectQuery {
 }
 
 // newSelectFollowRequesting returns a new select query for all rows in the follow_requests table with account_id = accountID.
-func newSelectFollowRequesting(db *DB, accountID string) *bun.SelectQuery {
+func newSelectFollowRequesting(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		TableExpr("?", bun.Ident("follow_requests")).
 		ColumnExpr("?", bun.Ident("id")).
@@ -317,7 +317,7 @@ func newSelectFollowRequesting(db *DB, accountID string) *bun.SelectQuery {
 }
 
 // newSelectFollows returns a new select query for all rows in the follows table with account_id = accountID.
-func newSelectFollows(db *DB, accountID string) *bun.SelectQuery {
+func newSelectFollows(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		Table("follows").
 		Column("id").
@@ -327,7 +327,7 @@ func newSelectFollows(db *DB, accountID string) *bun.SelectQuery {
 
 // newSelectLocalFollows returns a new select query for all rows in the follows table with
 // account_id = accountID where the corresponding account ID has a NULL domain (i.e. is local).
-func newSelectLocalFollows(db *DB, accountID string) *bun.SelectQuery {
+func newSelectLocalFollows(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		Table("follows").
 		Column("id").
@@ -344,7 +344,7 @@ func newSelectLocalFollows(db *DB, accountID string) *bun.SelectQuery {
 }
 
 // newSelectFollowers returns a new select query for all rows in the follows table with target_account_id = accountID.
-func newSelectFollowers(db *DB, accountID string) *bun.SelectQuery {
+func newSelectFollowers(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		Table("follows").
 		Column("id").
@@ -354,7 +354,7 @@ func newSelectFollowers(db *DB, accountID string) *bun.SelectQuery {
 
 // newSelectLocalFollowers returns a new select query for all rows in the follows table with
 // target_account_id = accountID where the corresponding account ID has a NULL domain (i.e. is local).
-func newSelectLocalFollowers(db *DB, accountID string) *bun.SelectQuery {
+func newSelectLocalFollowers(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		Table("follows").
 		Column("id").
@@ -371,7 +371,7 @@ func newSelectLocalFollowers(db *DB, accountID string) *bun.SelectQuery {
 }
 
 // newSelectBlocks returns a new select query for all rows in the blocks table with account_id = accountID.
-func newSelectBlocks(db *DB, accountID string) *bun.SelectQuery {
+func newSelectBlocks(db *bun.DB, accountID string) *bun.SelectQuery {
 	return db.NewSelect().
 		TableExpr("?", bun.Ident("blocks")).
 		ColumnExpr("?", bun.Ident("id")).
diff --git a/internal/db/bundb/report.go b/internal/db/bundb/report.go
index 5b0ae17f3..486bf09f0 100644
--- a/internal/db/bundb/report.go
+++ b/internal/db/bundb/report.go
@@ -32,7 +32,7 @@ import (
 )
 
 type reportDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/rule.go b/internal/db/bundb/rule.go
index ebfa89d15..e36053c38 100644
--- a/internal/db/bundb/rule.go
+++ b/internal/db/bundb/rule.go
@@ -32,7 +32,7 @@ import (
 )
 
 type ruleDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/search.go b/internal/db/bundb/search.go
index f9c2df1f8..f8ae529f7 100644
--- a/internal/db/bundb/search.go
+++ b/internal/db/bundb/search.go
@@ -57,7 +57,7 @@ import (
 // This isn't ideal, of course, but at least we could cover the most common use case of
 // a caller paging down through results.
 type searchDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/session.go b/internal/db/bundb/session.go
index 9310a6463..2177a57ae 100644
--- a/internal/db/bundb/session.go
+++ b/internal/db/bundb/session.go
@@ -24,10 +24,11 @@ import (
 
 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 	"github.com/superseriousbusiness/gotosocial/internal/id"
+	"github.com/uptrace/bun"
 )
 
 type sessionDB struct {
-	db *DB
+	db *bun.DB
 }
 
 func (s *sessionDB) GetSession(ctx context.Context) (*gtsmodel.RouterSession, error) {
diff --git a/internal/db/bundb/status.go b/internal/db/bundb/status.go
index 07a09050a..6d1788b5d 100644
--- a/internal/db/bundb/status.go
+++ b/internal/db/bundb/status.go
@@ -34,7 +34,7 @@ import (
 )
 
 type statusDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
@@ -330,7 +330,7 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error
 		// It is safe to run this database transaction within cache.Store
 		// as the cache does not attempt a mutex lock until AFTER hook.
 		//
-		return s.db.RunInTx(ctx, func(tx Tx) error {
+		return s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			// create links between this status and any emojis it uses
 			for _, i := range status.EmojiIDs {
 				if _, err := tx.
@@ -414,7 +414,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co
 		// It is safe to run this database transaction within cache.Store
 		// as the cache does not attempt a mutex lock until AFTER hook.
 		//
-		return s.db.RunInTx(ctx, func(tx Tx) error {
+		return s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 			// create links between this status and any emojis it uses
 			for _, i := range status.EmojiIDs {
 				if _, err := tx.
@@ -509,7 +509,7 @@ func (s *statusDB) DeleteStatusByID(ctx context.Context, id string) error {
 	// On return ensure status invalidated from cache.
 	defer s.state.Caches.GTS.Status.Invalidate("ID", id)
 
-	return s.db.RunInTx(ctx, func(tx Tx) error {
+	return s.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
 		// delete links between this status and any emojis it uses
 		if _, err := tx.
 			NewDelete().
@@ -697,6 +697,5 @@ func (s *statusDB) IsStatusBookmarkedBy(ctx context.Context, status *gtsmodel.St
 		TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")).
 		Where("? = ?", bun.Ident("status_bookmark.status_id"), status.ID).
 		Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID)
-
-	return s.db.Exists(ctx, q)
+	return exists(ctx, q)
 }
diff --git a/internal/db/bundb/statusbookmark.go b/internal/db/bundb/statusbookmark.go
index 742c13966..73fced9c3 100644
--- a/internal/db/bundb/statusbookmark.go
+++ b/internal/db/bundb/statusbookmark.go
@@ -29,7 +29,7 @@ import (
 )
 
 type statusBookmarkDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/statusfave.go b/internal/db/bundb/statusfave.go
index e0f018b68..d04578076 100644
--- a/internal/db/bundb/statusfave.go
+++ b/internal/db/bundb/statusfave.go
@@ -35,7 +35,7 @@ import (
 )
 
 type statusFaveDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/tag.go b/internal/db/bundb/tag.go
index 66ee8cb3a..e6297d2ab 100644
--- a/internal/db/bundb/tag.go
+++ b/internal/db/bundb/tag.go
@@ -28,7 +28,7 @@ import (
 )
 
 type tagDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/thread.go b/internal/db/bundb/thread.go
index 34c5f783a..a75515062 100644
--- a/internal/db/bundb/thread.go
+++ b/internal/db/bundb/thread.go
@@ -28,7 +28,7 @@ import (
 )
 
 type threadDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/timeline.go b/internal/db/bundb/timeline.go
index f2ba2a9d1..e6c7e482d 100644
--- a/internal/db/bundb/timeline.go
+++ b/internal/db/bundb/timeline.go
@@ -34,7 +34,7 @@ import (
 )
 
 type timelineDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/tombstone.go b/internal/db/bundb/tombstone.go
index c0e439720..64169213e 100644
--- a/internal/db/bundb/tombstone.go
+++ b/internal/db/bundb/tombstone.go
@@ -27,7 +27,7 @@ import (
 )
 
 type tombstoneDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/user.go b/internal/db/bundb/user.go
index a6fa142f2..2854c0caa 100644
--- a/internal/db/bundb/user.go
+++ b/internal/db/bundb/user.go
@@ -31,7 +31,7 @@ import (
 )
 
 type userDB struct {
-	db    *DB
+	db    *bun.DB
 	state *state.State
 }
 
diff --git a/internal/db/bundb/util.go b/internal/db/bundb/util.go
index cee20bbe1..e2dd392dc 100644
--- a/internal/db/bundb/util.go
+++ b/internal/db/bundb/util.go
@@ -18,6 +18,8 @@
 package bundb
 
 import (
+	"context"
+	"database/sql"
 	"slices"
 	"strings"
 
@@ -113,6 +115,25 @@ func whereStartsLike(
 	)
 }
 
+// exists checks the results of a SelectQuery for the existence of the data in question, masking ErrNoEntries errors.
+func exists(ctx context.Context, query *bun.SelectQuery) (bool, error) {
+	exists, err := query.Exists(ctx)
+	switch err {
+	case nil:
+		return exists, nil
+	case sql.ErrNoRows:
+		return false, nil
+	default:
+		return false, err
+	}
+}
+
+// notExists checks the results of a SelectQuery for the non-existence of the data in question, masking ErrNoEntries errors.
+func notExists(ctx context.Context, query *bun.SelectQuery) (bool, error) {
+	exists, err := exists(ctx, query)
+	return !exists, err
+}
+
 // loadPagedIDs loads a page of IDs from given SliceCache by `key`, resorting to `loadDESC` if required. Uses `page` to sort + page resulting IDs.
 // NOTE: IDs returned from `cache` / `loadDESC` MUST be in descending order, otherwise paging will not work correctly / return things out of order.
 func loadPagedIDs(cache *cache.SliceCache[string], key string, page *paging.Page, loadDESC func() ([]string, error)) ([]string, error) {