navidrome/plugins/host_kvstore.go
Deluan Quintão 8f0b4930ff
refactor(conf): replace eager dir creation with lazy Dir type (#5495)
* feat(conf): add Dir type with lazy directory creation

Introduces the Dir type that wraps a directory path string and defers
os.MkdirAll until the first call to Path() or MustPath(), using sync.Once
to ensure the creation happens exactly once. Implements fmt.Stringer,
encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration.
Includes Ginkgo/Gomega tests covering all methods and error paths.

* refactor(conf): replace eager dir creation with lazy Dir type

Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from
string to Dir. Remove all os.MkdirAll calls from Load() so directories
are created lazily on first Path()/MustPath() call. Artwork folder
creation was already handled at point-of-use in image_upload.go.

Add SnapshotConfig() to conf package for safe test config save/restore
that avoids copying sync.Once inside Dir fields. Fix copy-lock vet
warning in nativeapi/config.go by marshalling pointer instead of value.

* refactor(conf): migrate tests and db init to lazy Dir type

Update all test files to use conf.NewDir() for Dir field assignments.
Ensure DataFolder is created lazily when the database is first opened
in db.Db(). Remove eager directory creation from conf.Load() tests.

* fix(conf): address review findings for Dir type

- Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match
  original behavior). Add NewDirWithPerm for PluginsFolder (0700).
- Use Path() instead of MustPath() in db.Prune() to avoid logFatal
  from background cron job.
- Panic on marshal/unmarshal errors in SnapshotConfig (test helper).
- Clean up redundant String()/MustPath() calls in plugin manager.
- Remove dead code in dir_test.go.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(conf): add GoString to Dir for clean config dump output

Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path
string instead of internal struct fields (sync.Once, perm, err).
Also add TODO comment to configtest about removing the indirection.

* fix(dir): improve error logging in MustPath method

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(conf): address PR review feedback

- Ensure Plugins.Folder always uses 0700, even when user-configured
  (previously only the derived default got restrictive permissions).
- Create LogFile parent directory before opening, so LogFile paths
  inside a not-yet-created DataFolder work correctly.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-13 17:44:22 -03:00

379 lines
12 KiB
Go

package plugins
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/dustin/go-humanize"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/utils/slice"
)
const (
defaultMaxKVStoreSize = 1 * 1024 * 1024 // 1MB default
maxKeyLength = 256 // Max key length in bytes
)
// notExpiredFilter is the SQL condition to exclude expired keys.
const notExpiredFilter = "(expires_at IS NULL OR expires_at >= datetime('now'))"
const cleanupInterval = 1 * time.Hour
// kvstoreServiceImpl implements the host.KVStoreService interface.
// Each plugin gets its own SQLite database for isolation.
type kvstoreServiceImpl struct {
pluginName string
db *sql.DB
maxSize int64
cancel context.CancelFunc
wg sync.WaitGroup
}
// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database.
// The provided context controls the lifetime of the background cleanup goroutine.
func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) {
// Parse max size from permission, default to 1MB
maxSize := int64(defaultMaxKVStoreSize)
if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" {
parsed, err := humanize.ParseBytes(*perm.MaxSize)
if err != nil {
return nil, fmt.Errorf("invalid maxSize %q: %w", *perm.MaxSize, err)
}
maxSize = int64(parsed)
}
// Create plugin data directory
dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName)
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("creating plugin data directory: %w", err)
}
// Open SQLite database
dbPath := filepath.Join(dataDir, "kvstore.db")
db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off")
if err != nil {
return nil, fmt.Errorf("opening kvstore database: %w", err)
}
db.SetMaxOpenConns(3)
db.SetMaxIdleConns(1)
// Apply schema migrations
if err := createKVStoreSchema(db); err != nil {
db.Close()
return nil, fmt.Errorf("migrating kvstore schema: %w", err)
}
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)))
cleanupCtx, cancel := context.WithCancel(ctx)
svc := &kvstoreServiceImpl{
pluginName: pluginName,
db: db,
maxSize: maxSize,
cancel: cancel,
}
svc.wg.Add(1)
go svc.cleanupLoop(cleanupCtx)
return svc, nil
}
// createKVStoreSchema applies schema migrations to the kvstore database.
// New migrations must be appended at the end of the slice.
func createKVStoreSchema(db *sql.DB) error {
return migrateDB(db, []string{
`CREATE TABLE IF NOT EXISTS kvstore (
key TEXT PRIMARY KEY NOT NULL,
value BLOB NOT NULL,
size INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`ALTER TABLE kvstore ADD COLUMN expires_at DATETIME DEFAULT NULL`,
`CREATE INDEX idx_kvstore_expires_at ON kvstore(expires_at)`,
})
}
// storageUsed returns the current total storage used by non-expired keys.
func (s *kvstoreServiceImpl) storageUsed(ctx context.Context) (int64, error) {
var used int64
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size), 0) FROM kvstore WHERE `+notExpiredFilter).Scan(&used)
if err != nil {
return 0, fmt.Errorf("calculating storage used: %w", err)
}
return used, nil
}
// checkStorageLimit verifies that adding delta bytes would not exceed the storage limit.
func (s *kvstoreServiceImpl) checkStorageLimit(ctx context.Context, delta int64) error {
if delta <= 0 {
return nil
}
used, err := s.storageUsed(ctx)
if err != nil {
return err
}
newTotal := used + delta
if newTotal > s.maxSize {
return fmt.Errorf("storage limit exceeded: would use %s of %s allowed",
humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize)))
}
return nil
}
// setValue is the shared implementation for Set and SetWithTTL.
// A ttlSeconds of 0 means no expiration.
func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
if len(key) == 0 {
return fmt.Errorf("key cannot be empty")
}
if len(key) > maxKeyLength {
return fmt.Errorf("key exceeds maximum length of %d bytes", maxKeyLength)
}
newValueSize := int64(len(value))
// Get current size of this key (if it exists and not expired) to calculate delta
var oldSize int64
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&oldSize)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("checking existing key: %w", err)
}
if err := s.checkStorageLimit(ctx, newValueSize-oldSize); err != nil {
return err
}
// Compute expires_at: sql.NullString{Valid:false} sends NULL (no expiration),
// otherwise we send a concrete timestamp.
var expiresAt sql.NullString
if ttlSeconds > 0 {
expiresAt = sql.NullString{String: fmt.Sprintf("+%d seconds", ttlSeconds), Valid: true}
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO kvstore (key, value, size, created_at, updated_at, expires_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, datetime('now', ?))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
size = excluded.size,
updated_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at
`, key, value, newValueSize, expiresAt)
if err != nil {
return fmt.Errorf("storing value: %w", err)
}
log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize, "ttlSeconds", ttlSeconds)
return nil
}
// Set stores a byte value with the given key.
func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error {
return s.setValue(ctx, key, value, 0)
}
// SetWithTTL stores a byte value with the given key and a time-to-live.
func (s *kvstoreServiceImpl) SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
if ttlSeconds <= 0 {
return fmt.Errorf("ttlSeconds must be greater than 0")
}
return s.setValue(ctx, key, value, ttlSeconds)
}
// Get retrieves a byte value from storage.
func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) {
var value []byte
err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("reading value: %w", err)
}
log.Trace(ctx, "KVStore.Get", "plugin", s.pluginName, "key", key, "found", true)
return value, true, nil
}
// Delete removes a value from storage.
func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key)
if err != nil {
return fmt.Errorf("deleting value: %w", err)
}
log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key)
return nil
}
// Has checks if a key exists in storage.
func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&count)
if err != nil {
return false, fmt.Errorf("checking key: %w", err)
}
return count > 0, nil
}
// List returns all keys matching the given prefix.
func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string, error) {
var rows *sql.Rows
var err error
if prefix == "" {
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE `+notExpiredFilter+` ORDER BY key`)
} else {
// Escape special LIKE characters in prefix
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' AND `+notExpiredFilter+` ORDER BY key`, escapedPrefix+"%")
}
if err != nil {
return nil, fmt.Errorf("listing keys: %w", err)
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("scanning key: %w", err)
}
keys = append(keys, key)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating keys: %w", err)
}
log.Trace(ctx, "KVStore.List", "plugin", s.pluginName, "prefix", prefix, "count", len(keys))
return keys, nil
}
// GetStorageUsed returns the total storage used by this plugin in bytes.
func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) {
used, err := s.storageUsed(ctx)
if err != nil {
return 0, err
}
log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used)
return used, nil
}
// DeleteByPrefix removes all keys matching the given prefix.
func (s *kvstoreServiceImpl) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
if prefix == "" {
return 0, fmt.Errorf("prefix cannot be empty")
}
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key LIKE ? ESCAPE '\'`, escapedPrefix+"%")
if err != nil {
return 0, fmt.Errorf("deleting keys: %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("getting deleted count: %w", err)
}
log.Trace(ctx, "KVStore.DeleteByPrefix", "plugin", s.pluginName, "prefix", prefix, "deletedCount", count)
return count, nil
}
// GetMany retrieves multiple values in a single call, processing keys in batches.
func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if len(keys) == 0 {
return map[string][]byte{}, nil
}
const batchSize = 200
result := make(map[string][]byte)
for chunk := range slice.CollectChunks(slices.Values(keys), batchSize) {
placeholders := make([]string, len(chunk))
args := make([]any, len(chunk))
for i, key := range chunk {
placeholders[i] = "?"
args[i] = key
}
query := `SELECT key, value FROM kvstore WHERE key IN (` + strings.Join(placeholders, ",") + `) AND ` + notExpiredFilter //nolint:gosec // placeholders are always "?"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying values: %w", err)
}
for rows.Next() {
var key string
var value []byte
if err := rows.Scan(&key, &value); err != nil {
rows.Close()
return nil, fmt.Errorf("scanning value: %w", err)
}
result[key] = value
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterating values: %w", err)
}
rows.Close()
}
log.Trace(ctx, "KVStore.GetMany", "plugin", s.pluginName, "requested", len(keys), "found", len(result))
return result, nil
}
// cleanupLoop periodically removes expired keys from the database.
// It stops when the provided context is cancelled.
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupExpired(ctx)
}
}
}
// cleanupExpired removes all expired keys from the database to reclaim disk space.
func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) {
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
if err != nil {
log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err)
return
}
if count, err := result.RowsAffected(); err == nil && count > 0 {
log.Debug("KVStore cleanup completed", "plugin", s.pluginName, "deletedKeys", count)
}
}
// Close stops the cleanup goroutine and closes the SQLite database connection.
func (s *kvstoreServiceImpl) Close() error {
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
s.cancel()
s.wg.Wait()
return s.db.Close()
}
// Compile-time verification
var _ host.KVStoreService = (*kvstoreServiceImpl)(nil)