package database import ( "log/slog" "path" "time" "github.com/sbondCo/Watcharr/config" "github.com/sbondCo/Watcharr/database/entity" "github.com/sbondCo/Watcharr/database/migrate" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // Create a new database connection. // Also runs migrations, etc, before returning connection. // Any error returned from this func should always make our app Exit (caller // handled). // // NOTE: Our mock db used in tests mimics this function, so if this func is // changed, you should look at the mock db to make it match if it makes // sense, so our tests stay accurate to prod. func New() (*gorm.DB, error) { slog.Info("New: Opening new database connection") // Open the database. db, err := gorm.Open( sqlite.Open(path.Join(config.DataPath, "watcharr.db")), &gorm.Config{TranslateError: true}, ) if err != nil { slog.Error("New: Opening database failed.") return nil, err } // Setup the db (migrations, etc) if err := Setup(db); err != nil { slog.Error("New: Setting up connection failed!", "error", err) return nil, err } return db, nil } // Setup configures our db connection and applies migrations. // // NOTE: This exists as a separate function so it can be reused by our testutil // package that we want to have configured in the same way as the main db so // that tests reflect real life. func Setup(db *gorm.DB) error { if err := configure(db); err != nil { slog.Error("Setup: Configuring connection failed!", "error", err) return err } // Perform auto migration. slog.Info("Setup: AutoMigrating") err := db.AutoMigrate( &migrate.MigrationRecord{}, &entity.User{}, &entity.UserServices{}, &entity.Content{}, &entity.Watched{}, &entity.WatchedSeason{}, &entity.WatchedEpisode{}, &entity.Activity{}, &entity.Token{}, &entity.Follow{}, &entity.Image{}, &entity.Game{}, &entity.ArrRequest{}, &entity.Tag{}, ) if err != nil { slog.Error("Setup: Auto migration failed.") return err } slog.Info("Setup: AutoMigrated") // Perform our manual migrations. if err := migrate.Now(db); err != nil { slog.Error("Setup: Manual migrations failed.", "error", err) return err } // Optimize database. if err := optimize(db); err != nil { slog.Error("Setup: Optimizing database failed.", "error", err) return err } return nil } // Configure our SQLite database connection. // Some PRAGMAs need to be defined per-connection, so we do that here. func configure(db *gorm.DB) error { slog.Info("configure: Configuring connection.") // Synchronous: https://sqlite.org/pragma.html#pragma_synchronous // Configured to `FULL` because I'm slightly confused. // `NORMAL` is recommended for most apps with WAL, but you // lose "durability", which doesn't sound like a good thing to me... // personally I'm okay with less performance for the best durability. if res := db.Exec("PRAGMA synchronous=2"); res.Error != nil { slog.Error("configure: Configuring synchronous failed!") return res.Error } slog.Info("configure: Configured synchronous.") return nil } // Optimize the database. func optimize(db *gorm.DB) error { slog.Info("optimize: Running optimizations.") // WAL Checkpoint (aka commit anything in the WAL to the main db). // Seems best to do this to make sure it has happened, especially since // we are running vacuum next. // https://sqlite.org/pragma.html#pragma_wal_checkpoint timeBeforeQuery := time.Now() if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil { slog.Error("optimize: Checkpoint failed!") return res.Error } slog.Info("optimize: Checkpointed.", "took", time.Since(timeBeforeQuery)) // Optimize pragma. // Running with recommended argument for our new long-living connection. // https://sqlite.org/pragma.html#pragma_optimize timeBeforeQuery = time.Now() if res := db.Exec("PRAGMA optimize=0x10002"); res.Error != nil { slog.Error("optimize: Optimize pragma failed!") return res.Error } slog.Info("optimize: Optimize pragma succeeded.", "took", time.Since(timeBeforeQuery)) // Vacuum. // > VACUUM rebuilds the database file, repacking it into a minimal amount // > of disk space. // https://sqlite.org/lang_vacuum.html timeBeforeQuery = time.Now() if res := db.Exec("VACUUM"); res.Error != nil { slog.Error("optimize: Vacuum failed!") return res.Error } slog.Info("optimize: Vacuumed successfully.", "took", time.Since(timeBeforeQuery)) // WAL Checkpoint (aka commit anything in the WAL to the main db). // Do this after vacuum too to ensure we have a clean slate for this // startup. // https://sqlite.org/pragma.html#pragma_wal_checkpoint timeBeforeQuery = time.Now() if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil { slog.Error("optimize: Checkpoint failed!") return res.Error } slog.Info("optimize: Checkpointed.", "took", time.Since(timeBeforeQuery)) slog.Info("optimize: Done.") return nil } // Optimize task that is scheduled and ran every whenever. // So this func is for optimzations that we want to re-run every time the // task is scheduled for. func TaskOptimize(db *gorm.DB) error { slog.Info("TaskOptimize: Running optimizations.") // WAL Checkpoint (aka commit anything in the WAL to the main db). // To avoid our WAL file becoming huge, we checkpoint regularly. // https://sqlite.org/pragma.html#pragma_wal_checkpoint timeBeforeQuery := time.Now() if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil { slog.Error("TaskOptimize: Checkpoint failed!") return res.Error } slog.Info("TaskOptimize: Checkpointed.", "took", time.Since(timeBeforeQuery)) // Optimize pragma. // No args for our task as recommended. // https://sqlite.org/pragma.html#pragma_optimize timeBeforeQuery = time.Now() if res := db.Exec("PRAGMA optimize"); res.Error != nil { slog.Error("TaskOptimize: Optimize pragma failed!") return res.Error } slog.Info("TaskOptimize: Optimize pragma succeeded.", "took", time.Since(timeBeforeQuery)) slog.Info("TaskOptimize: Done.") return nil }