stash/pkg/sqlite/database.go
WithoutPants d8448ba37e
Add basename and parent_folders fields to Folder graphql interface (#6494)
* Add basename field to folder
* Add parent_folders field to folder
* Add basename column to folder table
* Add basename filter field
* Create missing folder hierarchies during migration
* Treat files/folders in zips where path can't be made relative as not found

Addresses an issue during clean where corrupt folder entries in zip files could not be removed due to an error during the call to Rel.
2026-02-27 10:58:11 +11:00

519 lines
13 KiB
Go

package sqlite
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
const (
maxWriteConnections = 1
// Number of database read connections to use
// The same value is used for both the maximum and idle limit,
// to prevent opening connections on the fly which has a notieable performance penalty.
// Fewer connections use less memory, more connections increase performance,
// but have diminishing returns.
// 10 was found to be a good tradeoff.
maxReadConnections = 10
// Idle connection timeout, in seconds
// Closes a connection after a period of inactivity, which saves on memory and
// causes the sqlite -wal and -shm files to be automatically deleted.
dbConnTimeout = 30 * time.Second
// environment variable to set the cache size
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 84
//go:embed migrations/*.sql
var migrationsBox embed.FS
var (
// ErrDatabaseNotInitialized indicates that the database is not
// initialized, usually due to an incomplete configuration.
ErrDatabaseNotInitialized = errors.New("database not initialized")
)
// ErrMigrationNeeded indicates that a database migration is needed
// before the database can be initialized
type MigrationNeededError struct {
CurrentSchemaVersion uint
RequiredSchemaVersion uint
}
func (e *MigrationNeededError) Error() string {
return fmt.Sprintf("database schema version %d does not match required schema version %d", e.CurrentSchemaVersion, e.RequiredSchemaVersion)
}
type MismatchedSchemaVersionError struct {
CurrentSchemaVersion uint
RequiredSchemaVersion uint
}
func (e *MismatchedSchemaVersionError) Error() string {
return fmt.Sprintf("schema version %d is incompatible with required schema version %d", e.CurrentSchemaVersion, e.RequiredSchemaVersion)
}
type storeRepository struct {
Blobs *BlobStore
File *FileStore
Folder *FolderStore
Image *ImageStore
Gallery *GalleryStore
GalleryChapter *GalleryChapterStore
Scene *SceneStore
SceneMarker *SceneMarkerStore
Performer *PerformerStore
SavedFilter *SavedFilterStore
Studio *StudioStore
Tag *TagStore
Group *GroupStore
}
type Database struct {
*storeRepository
readDB *sqlx.DB
writeDB *sqlx.DB
dbPath string
schemaVersion uint
lockChan chan struct{}
}
func NewDatabase() *Database {
fileStore := NewFileStore()
folderStore := NewFolderStore()
galleryStore := NewGalleryStore(fileStore, folderStore)
blobStore := NewBlobStore(BlobStoreOptions{})
performerStore := NewPerformerStore(blobStore)
studioStore := NewStudioStore(blobStore)
tagStore := NewTagStore(blobStore)
r := &storeRepository{}
*r = storeRepository{
Blobs: blobStore,
File: fileStore,
Folder: folderStore,
Scene: NewSceneStore(r, blobStore),
SceneMarker: NewSceneMarkerStore(),
Image: NewImageStore(r),
Gallery: galleryStore,
GalleryChapter: NewGalleryChapterStore(),
Performer: performerStore,
Studio: studioStore,
Tag: tagStore,
Group: NewGroupStore(blobStore),
SavedFilter: NewSavedFilterStore(),
}
ret := &Database{
storeRepository: r,
lockChan: make(chan struct{}, 1),
}
return ret
}
func (db *Database) SetBlobStoreOptions(options BlobStoreOptions) {
*db.Blobs = *NewBlobStore(options)
}
// Ready returns an error if the database is not ready to begin transactions.
func (db *Database) Ready() error {
if db.readDB == nil || db.writeDB == nil {
return ErrDatabaseNotInitialized
}
return nil
}
// Open initializes the database. If the database is new, then it
// performs a full migration to the latest schema version. Otherwise, any
// necessary migrations must be run separately using RunMigrations.
// Returns true if the database is new.
func (db *Database) Open(dbPath string) error {
db.lock()
defer db.unlock()
db.dbPath = dbPath
databaseSchemaVersion, err := db.getDatabaseSchemaVersion()
if err != nil {
return fmt.Errorf("getting database schema version: %w", err)
}
db.schemaVersion = databaseSchemaVersion
isNew := databaseSchemaVersion == 0
if isNew {
// new database, just run the migrations
if err := db.RunAllMigrations(); err != nil {
return fmt.Errorf("error running initial schema migrations: %w", err)
}
} else {
if databaseSchemaVersion > appSchemaVersion {
return &MismatchedSchemaVersionError{
CurrentSchemaVersion: databaseSchemaVersion,
RequiredSchemaVersion: appSchemaVersion,
}
}
// if migration is needed, then don't open the connection
if db.needsMigration() {
return &MigrationNeededError{
CurrentSchemaVersion: databaseSchemaVersion,
RequiredSchemaVersion: appSchemaVersion,
}
}
}
if err := db.initialise(); err != nil {
return err
}
if isNew {
// optimize database after migration
err = db.Optimise(context.Background())
if err != nil {
logger.Warnf("error while performing post-migration optimisation: %v", err)
}
}
return nil
}
// lock locks the database for writing. This method will block until the lock is acquired.
func (db *Database) lock() {
db.lockChan <- struct{}{}
}
// unlock unlocks the database
func (db *Database) unlock() {
// will block the caller if the lock is not held, so check first
select {
case <-db.lockChan:
return
default:
panic("database is not locked")
}
}
func (db *Database) Close() error {
db.lock()
defer db.unlock()
if db.readDB != nil {
if err := db.readDB.Close(); err != nil {
return err
}
db.readDB = nil
}
if db.writeDB != nil {
if err := db.writeDB.Close(); err != nil {
return err
}
db.writeDB = nil
}
return nil
}
func (db *Database) open(disableForeignKeys bool, writable bool) (*sqlx.DB, error) {
// https://github.com/mattn/go-sqlite3
url := "file:" + db.dbPath + "?_journal=WAL&_sync=NORMAL&_busy_timeout=50"
if !disableForeignKeys {
url += "&_fk=true"
}
if writable {
url += "&_txlock=immediate"
} else {
url += "&mode=ro"
}
// #5155 - set the cache size if the environment variable is set
// default is -2000 which is 2MB
if cacheSize := os.Getenv(cacheSizeEnv); cacheSize != "" {
url += "&_cache_size=" + cacheSize
}
conn, err := sqlx.Open(sqlite3Driver, url)
if err != nil {
return nil, fmt.Errorf("db.Open(): %w", err)
}
return conn, nil
}
func (db *Database) initialise() error {
if err := db.openReadDB(); err != nil {
return fmt.Errorf("opening read database: %w", err)
}
if err := db.openWriteDB(); err != nil {
return fmt.Errorf("opening write database: %w", err)
}
return nil
}
func (db *Database) openReadDB() error {
const (
disableForeignKeys = false
writable = false
)
var err error
db.readDB, err = db.open(disableForeignKeys, writable)
db.readDB.SetMaxOpenConns(maxReadConnections)
db.readDB.SetMaxIdleConns(maxReadConnections)
db.readDB.SetConnMaxIdleTime(dbConnTimeout)
return err
}
func (db *Database) openWriteDB() error {
const (
disableForeignKeys = false
writable = true
)
var err error
db.writeDB, err = db.open(disableForeignKeys, writable)
db.writeDB.SetMaxOpenConns(maxWriteConnections)
db.writeDB.SetMaxIdleConns(maxWriteConnections)
db.writeDB.SetConnMaxIdleTime(dbConnTimeout)
return err
}
func (db *Database) Remove() error {
databasePath := db.dbPath
err := db.Close()
if err != nil {
return fmt.Errorf("error closing database: %w", err)
}
err = os.Remove(databasePath)
if err != nil {
return fmt.Errorf("error removing database: %w", err)
}
// remove the -shm, -wal files ( if they exist )
walFiles := []string{databasePath + "-shm", databasePath + "-wal"}
for _, wf := range walFiles {
if exists, _ := fsutil.FileExists(wf); exists {
err = os.Remove(wf)
if err != nil {
return fmt.Errorf("error removing database: %w", err)
}
}
}
return nil
}
func (db *Database) Reset() error {
databasePath := db.dbPath
if err := db.Remove(); err != nil {
return err
}
if err := db.Open(databasePath); err != nil {
return fmt.Errorf("[reset DB] unable to initialize: %w", err)
}
return nil
}
// Backup the database. If db is nil, then uses the existing database
// connection.
func (db *Database) Backup(backupPath string) (err error) {
thisDB := db.writeDB
if thisDB == nil {
thisDB, err = sqlx.Connect(sqlite3Driver, "file:"+db.dbPath+"?_fk=true")
if err != nil {
return fmt.Errorf("open database %s failed: %w", db.dbPath, err)
}
defer thisDB.Close()
}
// if backup path is not in the same directory as the database,
// then backup to the same directory first, then move to the final location.
// This is to prevent errors if the backup directory is over a network share.
dbDir := filepath.Dir(db.dbPath)
moveAfter := filepath.Dir(backupPath) != dbDir
vacuumOut := backupPath
if moveAfter {
vacuumOut = filepath.Join(dbDir, filepath.Base(backupPath))
}
logger.Infof("Backing up database into: %s", vacuumOut)
_, err = thisDB.Exec(`VACUUM INTO "` + vacuumOut + `"`)
if err != nil {
return fmt.Errorf("vacuum failed: %w", err)
}
if moveAfter {
logger.Infof("Moving database backup to: %s", backupPath)
err = fsutil.SafeMove(vacuumOut, backupPath)
if err != nil {
return fmt.Errorf("moving database backup failed: %w", err)
}
}
return nil
}
func (db *Database) Anonymise(outPath string) error {
anon, err := NewAnonymiser(db, outPath)
if err != nil {
return err
}
return anon.Anonymise(context.Background())
}
func (db *Database) RestoreFromBackup(backupPath string) error {
logger.Infof("Restoring backup database %s into %s", backupPath, db.dbPath)
return os.Rename(backupPath, db.dbPath)
}
func (db *Database) AppSchemaVersion() uint {
return appSchemaVersion
}
func (db *Database) DatabasePath() string {
return db.dbPath
}
func (db *Database) DatabaseBackupPath(backupDirectoryPath string) string {
fn := fmt.Sprintf("%s.%d.%s", filepath.Base(db.dbPath), db.schemaVersion, time.Now().Format("20060102_150405"))
if backupDirectoryPath != "" {
return filepath.Join(backupDirectoryPath, fn)
}
return fn
}
func (db *Database) AnonymousDatabasePath(backupDirectoryPath string) string {
fn := fmt.Sprintf("%s.anonymous.%d.%s", filepath.Base(db.dbPath), db.schemaVersion, time.Now().Format("20060102_150405"))
if backupDirectoryPath != "" {
return filepath.Join(backupDirectoryPath, fn)
}
return fn
}
func (db *Database) Version() uint {
return db.schemaVersion
}
func (db *Database) Optimise(ctx context.Context) error {
logger.Info("Optimising database")
err := db.Analyze(ctx)
if err != nil {
return fmt.Errorf("performing optimization: %w", err)
}
err = db.Vacuum(ctx)
if err != nil {
return fmt.Errorf("performing vacuum: %w", err)
}
return nil
}
// Vacuum runs a VACUUM on the database, rebuilding the database file into a minimal amount of disk space.
func (db *Database) Vacuum(ctx context.Context) error {
_, err := db.writeDB.ExecContext(ctx, "VACUUM")
return err
}
// Analyze runs an ANALYZE on the database to improve query performance.
func (db *Database) Analyze(ctx context.Context) error {
return analyze(ctx, db.writeDB)
}
// analyze runs an ANALYZE on the database to improve query performance.
func analyze(ctx context.Context, db *sqlx.DB) error {
_, err := db.ExecContext(ctx, "ANALYZE")
return err
}
// flushWAL flushes the Write-Ahead Log (WAL) to the main database file.
// It also truncates the WAL file to 0 bytes.
func flushWAL(ctx context.Context, db *sqlx.DB) error {
_, err := db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)")
return err
}
func (db *Database) ExecSQL(ctx context.Context, query string, args []interface{}) (*int64, *int64, error) {
wrapper := dbWrapperType{}
result, err := wrapper.Exec(ctx, query, args...)
if err != nil {
return nil, nil, err
}
var rowsAffected *int64
ra, err := result.RowsAffected()
if err == nil {
rowsAffected = &ra
}
var lastInsertId *int64
li, err := result.LastInsertId()
if err == nil {
lastInsertId = &li
}
return rowsAffected, lastInsertId, nil
}
func (db *Database) QuerySQL(ctx context.Context, query string, args []interface{}) ([]string, [][]interface{}, error) {
wrapper := dbWrapperType{}
rows, err := wrapper.QueryxContext(ctx, query, args...)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, nil, err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return nil, nil, err
}
var ret [][]interface{}
for rows.Next() {
row, err := rows.SliceScan()
if err != nil {
return nil, nil, err
}
ret = append(ret, row)
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
return cols, ret, nil
}