package persistence
import (
"context"
"errors"
"time"
"github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("SQLStore", func() {
var ds model.DataStore
var ctx context.Context
BeforeEach(func() {
ds = New(db.Db())
ctx = context.Background()
})
Describe("WithTx", func() {
Context("When block returns nil", func() {
It("commits changes to the DB", func() {
err := ds.WithTx(func(tx model.DataStore) error {
pl := tx.Player()
err := pl.Put(ctx, &model.Player{ID: "666", UserId: "userid"})
Expect(err).ToNot(HaveOccurred())
pr := tx.Property()
err = pr.Put(ctx, "777", "value")
Expect(err).ToNot(HaveOccurred())
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(ds.Player().Get(ctx, "666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"}))
Expect(ds.Property().Get(ctx, "777")).To(Equal("value"))
})
})
Context("When block returns an error", func() {
It("rollbacks changes to the DB", func() {
err := ds.WithTx(func(tx model.DataStore) error {
pr := tx.Property()
err := pr.Put(ctx, "999", "value")
Expect(err).ToNot(HaveOccurred())
pl := tx.Player()
err = pl.Put(ctx, &model.Player{ID: "888"})
Expect(err).To(HaveOccurred())
return err
})
Expect(err).To(HaveOccurred())
_, err = ds.Property().Get(ctx, "999")
Expect(err).To(MatchError(model.ErrNotFound))
_, err = ds.Player().Get(ctx, "888")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
})
Describe("WithTxRetry", func() {
busy := sqlite3.Error{Code: sqlite3.ErrBusy}
BeforeEach(func() {
DeferCleanup(func(d time.Duration) { txRetryDelay = d }, txRetryDelay)
txRetryDelay = 0
})
It("reruns a busy transaction from a clean rollback", func() {
var attempts []bool
err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
attempts = append(attempts, hasBusyRetry(ctx))
Expect(tx.Property().Put(ctx, "retry-key", "attempt")).To(Succeed())
if len(attempts) < 3 {
return busy
}
return nil
})
Expect(err).ToNot(HaveOccurred())
Expect(attempts).To(Equal([]bool{true, true, true}))
Expect(ds.Property().Get(ctx, "retry-key")).To(Equal("attempt"))
})
It("gives up after the last retry, which is not marked as retried", func() {
var attempts []bool
err := ds.WithTxRetry(ctx, func(ctx context.Context, _ model.DataStore) error {
attempts = append(attempts, hasBusyRetry(ctx))
return busy
})
Expect(db.IsBusy(err)).To(BeTrue())
Expect(attempts).To(Equal([]bool{true, true, true, false}))
})
It("does not rerun on other errors", func() {
calls := 0
err := ds.WithTxRetry(ctx, func(context.Context, model.DataStore) error {
calls++
return sqlite3.Error{Code: sqlite3.ErrConstraint}
})
Expect(err).To(HaveOccurred())
Expect(calls).To(Equal(1))
})
It("does not rerun when called inside a transaction", func() {
calls := 0
err := ds.WithTx(func(tx model.DataStore) error {
return tx.WithTxRetry(ctx, func(context.Context, model.DataStore) error {
calls++
return busy
})
})
Expect(db.IsBusy(err)).To(BeTrue())
Expect(calls).To(Equal(1))
})
It("joins the enclosing transaction instead of opening another", func() {
rollback := errors.New("rollback")
err := ds.WithTx(func(tx model.DataStore) error {
Expect(tx.Property().Put(ctx, "outer-key", "v")).To(Succeed())
Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error {
Expect(inner.Property().Get(ctx, "outer-key")).To(Equal("v"))
return inner.Property().Put(ctx, "inner-key", "v")
})).To(Succeed())
return rollback
})
Expect(err).To(MatchError(rollback))
_, err = ds.Property().Get(ctx, "inner-key")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
})