mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 03:47:18 +02:00
162 lines
5.9 KiB
Go
162 lines
5.9 KiB
Go
|
|
package db_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/navidrome/navidrome/consts"
|
||
|
|
"github.com/navidrome/navidrome/db"
|
||
|
|
. "github.com/onsi/ginkgo/v2"
|
||
|
|
. "github.com/onsi/gomega"
|
||
|
|
)
|
||
|
|
|
||
|
|
var _ = Describe("Optimize", func() {
|
||
|
|
var (
|
||
|
|
ctx context.Context
|
||
|
|
database *sql.DB
|
||
|
|
now time.Time
|
||
|
|
)
|
||
|
|
|
||
|
|
BeforeEach(func() {
|
||
|
|
ctx = context.Background()
|
||
|
|
now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC)
|
||
|
|
var err error
|
||
|
|
database, err = sql.Open(db.Dialect, "file::memory:")
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
DeferCleanup(database.Close)
|
||
|
|
|
||
|
|
_, err = database.Exec(`create table property(
|
||
|
|
id varchar(255) primary key,
|
||
|
|
value varchar(255) not null default ''
|
||
|
|
)`)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
_, err = database.Exec("create table analyze_probe(id integer primary key, flag int)")
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
_, err = database.Exec(`insert into analyze_probe(flag)
|
||
|
|
with recursive s(x) as (select 1 union all select x+1 from s where x < 3000)
|
||
|
|
select 0 from s`)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
_, err = database.Exec("create index probe_flag on analyze_probe(flag)")
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
_, err = database.Exec("analyze")
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
})
|
||
|
|
|
||
|
|
putProperty := func(key, value string) {
|
||
|
|
_, err := database.Exec(`insert into property(id, value) values(?, ?)
|
||
|
|
on conflict(id) do update set value=excluded.value`, key, value)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
}
|
||
|
|
|
||
|
|
getProperty := func(key string) string {
|
||
|
|
var value string
|
||
|
|
Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed())
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
poisonStats := func() {
|
||
|
|
_, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'")
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
}
|
||
|
|
|
||
|
|
It("replaces poisoned planner statistics with full-quality ones", func() {
|
||
|
|
poisonStats()
|
||
|
|
putProperty(consts.DBAnalyzePendingKey, "1")
|
||
|
|
|
||
|
|
Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed())
|
||
|
|
|
||
|
|
var stat string
|
||
|
|
err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
// A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count.
|
||
|
|
Expect(stat).To(Equal("3000 3000"))
|
||
|
|
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||
|
|
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||
|
|
})
|
||
|
|
|
||
|
|
It("runs when no previous analysis was recorded", func() {
|
||
|
|
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
Expect(ran).To(BeTrue())
|
||
|
|
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||
|
|
})
|
||
|
|
|
||
|
|
It("skips a recent analysis when no refresh is pending", func() {
|
||
|
|
lastAnalyze := now.Add(-23 * time.Hour)
|
||
|
|
putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano))
|
||
|
|
putProperty(consts.DBAnalyzePendingKey, "0")
|
||
|
|
poisonStats()
|
||
|
|
|
||
|
|
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
Expect(ran).To(BeFalse())
|
||
|
|
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano)))
|
||
|
|
|
||
|
|
var stat string
|
||
|
|
Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed())
|
||
|
|
Expect(stat).To(Equal("3000 50"))
|
||
|
|
})
|
||
|
|
|
||
|
|
It("runs when the previous analysis is stale", func() {
|
||
|
|
putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano))
|
||
|
|
putProperty(consts.DBAnalyzePendingKey, "0")
|
||
|
|
|
||
|
|
ran, err := db.OptimizeDBIfNeeded(ctx, database, now)
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
Expect(ran).To(BeTrue())
|
||
|
|
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||
|
|
})
|
||
|
|
|
||
|
|
It("runs when a refresh is pending even if the previous analysis is recent", func() {
|
||
|
|
putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano))
|
||
|
|
putProperty(consts.DBAnalyzePendingKey, "1")
|
||
|
|
|
||
|
|
ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour))
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
Expect(ran).To(BeTrue())
|
||
|
|
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||
|
|
})
|
||
|
|
|
||
|
|
DescribeTable("backs off after consecutive analysis failures",
|
||
|
|
func(failures string, retryDelay time.Duration) {
|
||
|
|
putProperty(consts.DBAnalyzePendingKey, "1")
|
||
|
|
putProperty(consts.DBAnalyzeFailureCountKey, failures)
|
||
|
|
putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano))
|
||
|
|
|
||
|
|
ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond))
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
Expect(ran).To(BeFalse())
|
||
|
|
|
||
|
|
ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay))
|
||
|
|
Expect(err).ToNot(HaveOccurred())
|
||
|
|
Expect(ran).To(BeTrue())
|
||
|
|
Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0"))
|
||
|
|
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0"))
|
||
|
|
},
|
||
|
|
Entry("for 30 minutes after the first failure", "1", 30*time.Minute),
|
||
|
|
Entry("for one hour after the second failure", "2", time.Hour),
|
||
|
|
Entry("for two hours after the third failure", "3", 2*time.Hour),
|
||
|
|
Entry("for 24 hours after the fourth failure", "4", 24*time.Hour),
|
||
|
|
)
|
||
|
|
|
||
|
|
It("records consecutive analysis failures", func() {
|
||
|
|
putProperty(consts.DBAnalyzeFailureCountKey, "2")
|
||
|
|
|
||
|
|
Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed())
|
||
|
|
|
||
|
|
Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3"))
|
||
|
|
Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano)))
|
||
|
|
Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1"))
|
||
|
|
})
|
||
|
|
|
||
|
|
It("does not record success when analysis fails", func() {
|
||
|
|
lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano)
|
||
|
|
putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze)
|
||
|
|
canceledCtx, cancel := context.WithCancel(ctx)
|
||
|
|
cancel()
|
||
|
|
|
||
|
|
Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled")))
|
||
|
|
Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze))
|
||
|
|
})
|
||
|
|
})
|