mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* feat(plugins): add expires_at column to kvstore schema
* feat(plugins): filter expired keys in kvstore Get, Has, List
* feat(plugins): add periodic cleanup of expired kvstore keys
* feat(plugins): add SetWithTTL, DeleteByPrefix, and GetMany to kvstore
Add three new methods to the KVStore host service:
- SetWithTTL: store key-value pairs with automatic expiration
- DeleteByPrefix: remove all keys matching a prefix in one operation
- GetMany: retrieve multiple values in a single call
All methods include comprehensive unit tests covering edge cases,
expiration behavior, size tracking, and LIKE-special characters.
* feat(plugins): regenerate code and update test plugin for new kvstore methods
Regenerate host function wrappers and PDK bindings for Go, Python,
and Rust. Update the test-kvstore plugin to exercise SetWithTTL,
DeleteByPrefix, and GetMany.
* feat(plugins): add integration tests for new kvstore methods
Add WASM integration tests for SetWithTTL, DeleteByPrefix, and GetMany
operations through the plugin boundary, verifying end-to-end behavior
including TTL expiration, prefix deletion, and batch retrieval.
* fix(plugins): address lint issues in kvstore implementation
Handle tx.Rollback error return and suppress gosec false positive
for parameterized SQL query construction in GetMany.
* fix(plugins): Set clears expires_at when overwriting a TTL'd key
Previously, calling Set() on a key that was stored with SetWithTTL()
would leave the expires_at value intact, causing the key to silently
expire even though Set implies permanent storage.
Also excludes expired keys from currentSize calculation at startup.
* refactor(plugins): simplify kvstore by removing in-memory size cache
Replaced the in-memory currentSize cache (atomic.Int64), periodic cleanup
timer, and mutex with direct database queries for storage accounting.
This eliminates race conditions and cache drift issues at negligible
performance cost for plugin-sized datasets. Also unified Set and
SetWithTTL into a shared setValue method, simplified DeleteByPrefix to
use RowsAffected instead of a transaction, and added an index on
expires_at for efficient expiration filtering.
* feat(plugins): add generic SQLite migration helper and refactor kvstore schema
Add a reusable migrateDB helper that tracks schema versions via SQLite's
PRAGMA user_version and applies pending migrations transactionally. Replace
the ad-hoc createKVStoreSchema function in kvstore with a declarative
migrations slice, making it easy to add future schema changes. Remove the
now-redundant schema migration test since migrateDB has its own test suite
and every kvstore test exercises the migrations implicitly.
Signed-off-by: Deluan <deluan@navidrome.org>
* fix(plugins): harden kvstore with explicit NULL handling, prefix validation, and cleanup timeout
- Use sql.NullString for expires_at to explicitly send NULL instead of
relying on datetime('now', '') returning NULL by accident
- Reject empty prefix in DeleteByPrefix to prevent accidental data wipe
- Add 5s timeout context to cleanupExpired on Close
- Replace time.Sleep in unit tests with pre-expired timestamps
Signed-off-by: Deluan <deluan@navidrome.org>
* refactor(plugins): use batch processing in GetMany
Process keys in chunks of 200 using slice.CollectChunks to avoid
hitting SQLite's SQLITE_MAX_VARIABLE_NUMBER limit with large key sets.
* feat(plugins): add periodic cleanup goroutine for expired kvstore keys
Use the manager's context to control a background goroutine that purges
expired keys every hour, stopping naturally on shutdown when the context
is cancelled.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
100 lines
2.5 KiB
Go
100 lines
2.5 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"database/sql"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("migrateDB", func() {
|
|
var db *sql.DB
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
db, err = sql.Open("sqlite3", ":memory:")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
})
|
|
|
|
getUserVersion := func() int {
|
|
var version int
|
|
Expect(db.QueryRow(`PRAGMA user_version`).Scan(&version)).To(Succeed())
|
|
return version
|
|
}
|
|
|
|
It("applies all migrations on a fresh database", func() {
|
|
migrations := []string{
|
|
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
|
|
`ALTER TABLE test ADD COLUMN email TEXT`,
|
|
}
|
|
|
|
Expect(migrateDB(db, migrations)).To(Succeed())
|
|
Expect(getUserVersion()).To(Equal(2))
|
|
|
|
// Verify schema
|
|
_, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("skips already applied migrations", func() {
|
|
migrations1 := []string{
|
|
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
|
|
}
|
|
Expect(migrateDB(db, migrations1)).To(Succeed())
|
|
Expect(getUserVersion()).To(Equal(1))
|
|
|
|
// Add a new migration
|
|
migrations2 := []string{
|
|
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
|
|
`ALTER TABLE test ADD COLUMN email TEXT`,
|
|
}
|
|
Expect(migrateDB(db, migrations2)).To(Succeed())
|
|
Expect(getUserVersion()).To(Equal(2))
|
|
|
|
// Verify the new column exists
|
|
_, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
It("is a no-op when all migrations are applied", func() {
|
|
migrations := []string{
|
|
`CREATE TABLE test (id INTEGER PRIMARY KEY)`,
|
|
}
|
|
Expect(migrateDB(db, migrations)).To(Succeed())
|
|
Expect(migrateDB(db, migrations)).To(Succeed())
|
|
Expect(getUserVersion()).To(Equal(1))
|
|
})
|
|
|
|
It("is a no-op with empty migrations slice", func() {
|
|
Expect(migrateDB(db, nil)).To(Succeed())
|
|
Expect(getUserVersion()).To(Equal(0))
|
|
})
|
|
|
|
It("rolls back on failure", func() {
|
|
migrations := []string{
|
|
`CREATE TABLE test (id INTEGER PRIMARY KEY)`,
|
|
`INVALID SQL STATEMENT`,
|
|
}
|
|
|
|
err := migrateDB(db, migrations)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("migration 2 failed"))
|
|
|
|
// Version should remain 0 (rolled back)
|
|
Expect(getUserVersion()).To(Equal(0))
|
|
|
|
// Table should not exist (rolled back)
|
|
_, err = db.Exec(`INSERT INTO test (id) VALUES (1)`)
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|