
With a fuller database, 2 seconds is apparently not always long enough, so increase the timeout to 10 seconds.
190 lines
5.1 KiB
Go
190 lines
5.1 KiB
Go
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 = 10 * 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"`
|
|
}
|
|
|
|
// PeriodicIntegrityCheck periodically checks the database integrity.
|
|
// This function only returns when the context is done.
|
|
func (db *DB) PeriodicIntegrityCheck(
|
|
ctx context.Context,
|
|
period time.Duration,
|
|
onErrorCallback func(),
|
|
) {
|
|
if period == 0 {
|
|
log.Info().Msg("database: periodic integrity check disabled")
|
|
return
|
|
}
|
|
|
|
log.Info().
|
|
Stringer("period", period).
|
|
Msg("database: periodic integrity check starting")
|
|
defer log.Debug().Msg("database: periodic integrity check stopping")
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(period):
|
|
case <-db.consistencyCheckRequests:
|
|
}
|
|
|
|
ok := db.performIntegrityCheck(ctx)
|
|
if !ok {
|
|
log.Error().Msg("database: periodic integrity check failed")
|
|
onErrorCallback()
|
|
}
|
|
}
|
|
}
|
|
|
|
// RequestIntegrityCheck triggers a check of the database persistency.
|
|
func (db *DB) RequestIntegrityCheck() {
|
|
select {
|
|
case db.consistencyCheckRequests <- struct{}{}:
|
|
// Don't do anything, the work is done.
|
|
default:
|
|
log.Debug().Msg("database: could not trigger integrity check, another check might already be queued.")
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
log.Debug().Msg("database: performing integrity check")
|
|
|
|
db.ensureForeignKeysEnabled()
|
|
|
|
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
|
|
}
|
|
|
|
// ensureForeignKeysEnabled checks whether foreign keys are enabled, and if not,
|
|
// tries to enable them.
|
|
//
|
|
// This is likely caused by either GORM or its embedded SQLite creating a new
|
|
// connection to the low-level SQLite driver. Unfortunately the GORM-embedded
|
|
// SQLite doesn't have an 'on-connect' callback function to always enable
|
|
// foreign keys.
|
|
func (db *DB) ensureForeignKeysEnabled() {
|
|
fkEnabled, err := db.areForeignKeysEnabled()
|
|
|
|
if err != nil {
|
|
log.Error().AnErr("cause", err).Msg("database: could not check whether foreign keys are enabled")
|
|
return
|
|
}
|
|
|
|
if fkEnabled {
|
|
return
|
|
}
|
|
|
|
log.Warn().Msg("database: foreign keys are disabled, re-enabling them")
|
|
if err := db.pragmaForeignKeys(true); err != nil {
|
|
log.Error().AnErr("cause", err).Msg("database: error re-enabling foreign keys")
|
|
return
|
|
}
|
|
}
|