refactor: replace md5 with xxh3 for faster and more efficient hashing

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-08-19 09:19:47 -04:00
parent 2dab4b4048
commit 6d8a3e48ee
8 changed files with 19 additions and 22 deletions

View File

@ -27,6 +27,9 @@ linters:
disable:
- staticcheck
settings:
errcheck:
exclude-functions:
- (*github.com/zeebo/xxh3.Hasher).Write
gocritic:
disable-all: true
enabled-checks:

View File

@ -2,8 +2,6 @@ package artwork
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"slices"
"strconv"
@ -16,6 +14,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
@ -66,8 +65,7 @@ func FingerprintInputs() []FingerprintInput {
func ConfigFingerprint() string {
values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value })
raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch)
sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive
return hex.EncodeToString(sum[:])
return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
}
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.

View File

@ -135,7 +135,7 @@ var _ = Describe("Housekeeping", func() {
conf.Server.EnableExternalServices = true
conf.Server.EnableM3UExternalAlbumArt = false
Expect(ConfigFingerprint()).To(Equal("7e537a22febc07d3d5ca40546e88da54"))
Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d"))
})
It("reports the config inputs it hashes, so a change can be traced to a setting", func() {

View File

@ -2,7 +2,6 @@ package model
import (
"cmp"
"crypto/md5"
"encoding/json"
"fmt"
"iter"
@ -20,6 +19,7 @@ import (
"github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
type MediaFile struct {
@ -232,7 +232,7 @@ func (mf MediaFile) Hash() string {
ZeroNil: true,
}
hash, _ := hashstructure.Hash(mf, opts)
sum := md5.New()
sum := xxh3.New()
sum.Write(fmt.Appendf(nil, "%d", hash))
sum.Write(mf.Tags.Hash())
sum.Write(mf.Participants.Hash())

View File

@ -715,14 +715,10 @@ var _ = Describe("MediaFile.Movements", func() {
})
var _ = Describe("MediaFile.Hash", func() {
// Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes,
// or every file would be spuriously re-imported on the next scan.
// Golden hashes were captured at 46221d516 when those fields were plain ints.
It("keeps hashes identical to the pre-pointer-conversion values", func() {
// Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning
// them guarantees the pointer conversion cannot trigger a full-library re-import.
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622"))
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689"))
// Pins the hash formula: an accidental change spuriously re-imports every file on the next scan.
It("hashes to a stable value", func() {
Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090"))
Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538"))
})
It("changes the hash when a pointer field has a value", func() {
base := MediaFile{Title: "Song"}

View File

@ -2,12 +2,12 @@ package model
import (
"cmp"
"crypto/md5"
"fmt"
"slices"
"strings"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
var (
@ -193,7 +193,7 @@ func (p Participants) Hash() []byte {
flattened = append(flattened, role.String()+":"+strings.Join(ids, "/"))
}
slices.Sort(flattened)
sum := md5.New()
sum := xxh3.New()
sum.Write([]byte(strings.Join(flattened, "|")))
return sum.Sum(nil)
}

View File

@ -2,13 +2,13 @@ package model
import (
"cmp"
"crypto/md5"
"fmt"
"slices"
"strings"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/slice"
"github.com/zeebo/xxh3"
)
type Tag struct {
@ -117,7 +117,7 @@ func (t Tags) Hash() []byte {
}
ids := t.IDs()
slices.Sort(ids)
sum := md5.New()
sum := xxh3.New()
sum.Write([]byte(strings.Join(ids, "|")))
return sum.Sum(nil)
}

View File

@ -2,7 +2,6 @@ package persistence
import (
"context"
"crypto/md5"
"database/sql"
"errors"
"fmt"
@ -23,6 +22,7 @@ import (
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx"
"github.com/zeebo/xxh3"
)
// sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB.
@ -298,8 +298,8 @@ func (r sqlRepository) visibleLibraryIDs() ([]int, error) {
func (r sqlRepository) seedKey() string {
// Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed
// used in the query. Hashing the user ID and converting it to a hex string will do the trick
userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID))
return fmt.Sprintf("%s|%x", r.tableName, userIDHash)
userIDHash := xxh3.Hash([]byte(loggedUser(r.ctx).ID))
return fmt.Sprintf("%s|%016x", r.tableName, userIDHash)
}
func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {