diff --git a/internal/manager/persistence/db.go b/internal/manager/persistence/db.go index b5b56abc..08e60779 100644 --- a/internal/manager/persistence/db.go +++ b/internal/manager/persistence/db.go @@ -15,7 +15,7 @@ import ( "github.com/glebarez/sqlite" ) -const vacuumPeriod = 1 * time.Hour +const checkPeriod = 1 * time.Hour // DB provides the database interface. type DB struct { @@ -57,6 +57,10 @@ func OpenDB(ctx context.Context, dsn string) (*DB, error) { } // Perfom some maintenance at startup. + if !db.performIntegrityCheck(ctx) { + return nil, ErrIntegrity + } + db.vacuum() if err := db.migrate(); err != nil { @@ -147,27 +151,6 @@ func nowFunc() time.Time { return time.Now().UTC() } -// PeriodicMaintenanceLoop periodically vacuums the database. -// This function only returns when the context is done. -func (db *DB) PeriodicMaintenanceLoop(ctx context.Context) { - log.Debug().Msg("periodic database maintenance loop starting") - defer log.Debug().Msg("periodic database maintenance loop stopping") - - var waitTime time.Duration - - for { - select { - case <-ctx.Done(): - return - case <-time.After(waitTime): - waitTime = vacuumPeriod - } - - log.Debug().Msg("vacuuming database") - db.vacuum() - } -} - // vacuum executes the SQL "VACUUM" command, and logs any errors. func (db *DB) vacuum() { tx := db.gormDB.Exec("vacuum") diff --git a/internal/manager/persistence/integrity.go b/internal/manager/persistence/integrity.go new file mode 100644 index 00000000..6f2f15c9 --- /dev/null +++ b/internal/manager/persistence/integrity.go @@ -0,0 +1,114 @@ +package persistence + +// SPDX-License-Identifier: GPL-3.0-or-later + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog/log" +) + +var ErrIntegrity = errors.New("database integrity check failed") + +const integrityCheckTimeout = 2 * time.Second + +type PragmaIntegrityCheckResult struct { + Description string `gorm:"column:integrity_check"` +} + +type PragmaForeignKeyCheckResult struct { + Table string `gorm:"column:table"` + RowID int `gorm:"column:rowid"` + Parent string `gorm:"column:parent"` + FKID int `gorm:"column:fkid"` +} + +// performIntegrityCheck uses a few 'pragma' SQL statements to do some integrity checking. +// Returns true on OK, false if there was an issue. Issues are always logged. +func (db *DB) performIntegrityCheck(ctx context.Context) (ok bool) { + checkCtx, cancel := context.WithTimeout(ctx, integrityCheckTimeout) + defer cancel() + + if !db.pragmaIntegrityCheck(checkCtx) { + return false + } + return db.pragmaForeignKeyCheck(checkCtx) +} + +// pragmaIntegrityCheck checks database file integrity. This does not include +// foreign key checks. +// +// Returns true on OK, false if there was an issue. Issues are always logged. +// +// See https: //www.sqlite.org/pragma.html#pragma_integrity_check +func (db *DB) pragmaIntegrityCheck(ctx context.Context) (ok bool) { + var issues []PragmaIntegrityCheckResult + + tx := db.gormDB.WithContext(ctx). + Raw("PRAGMA integrity_check"). + Scan(&issues) + if tx.Error != nil { + log.Error().Err(tx.Error).Msg("database error checking integrity") + return false + } + + switch len(issues) { + case 0: + log.Warn().Msg("database integrity check returned nothing, expected explicit 'ok'; treating as an implicit 'ok'") + return true + case 1: + if issues[0].Description == "ok" { + log.Debug().Msg("database integrity check ok") + return true + } + } + + log.Error().Int("num_issues", len(issues)).Msg("database integrity check failed") + for _, issue := range issues { + log.Error(). + Str("description", issue.Description). + Msg("database integrity check failure") + } + + return false +} + +// pragmaForeignKeyCheck checks whether all foreign key constraints are still valid. +// +// SQLite has optional foreign key relations, so even though Flamenco Manager +// always enables these on startup, at some point there could be some issue +// causing these checks to be skipped. +// +// Returns true on OK, false if there was an issue. Issues are always logged. +// +// See https: //www.sqlite.org/pragma.html#pragma_foreign_key_check +func (db *DB) pragmaForeignKeyCheck(ctx context.Context) (ok bool) { + var issues []PragmaForeignKeyCheckResult + + tx := db.gormDB.WithContext(ctx). + Raw("PRAGMA foreign_key_check"). + Scan(&issues) + if tx.Error != nil { + log.Error().Err(tx.Error).Msg("database error checking foreign keys") + return false + } + + if len(issues) == 0 { + log.Debug().Msg("database foreign key check ok") + return true + } + + log.Error().Int("num_issues", len(issues)).Msg("database foreign key check failed") + for _, issue := range issues { + log.Error(). + Str("table", issue.Table). + Int("rowid", issue.RowID). + Str("parent", issue.Parent). + Int("fkid", issue.FKID). + Msg("database foreign key relation missing") + } + + return false +}