mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* fix(plugins): stop the cache janitor when a plugin cache is dropped
newCacheService started a ttlcache janitor goroutine that only stopped via the
explicit Close() path, so a cache service that was discarded without being closed
leaked its janitor for the process lifetime. It now registers the same
runtime.AddCleanup safety net that utils/cache.simpleCache already uses.
* fix(scanner): stop splitting multi-byte characters when truncating tags
sanitize() capped tag values with a byte slice, so a value whose limit falls in
the middle of a multi-byte character was stored as invalid UTF-8. defaultMaxTagLength
is 1024, which is not a multiple of 3, so any sufficiently long CJK title hit this.
Only trailing invalid bytes are trimmed, leaving bad bytes elsewhere in the value
untouched.
* fix(scanner): store MusicBrainz ids in their canonical form
uuid.Parse accepts a UUID wrapped in any two bytes, as well as braced and urn:
forms, but sanitize() returned the raw string. A tag like {<mbid>} or a quoted
value was therefore persisted with its wrapper into the mbz_* columns, where the
exact-match MBID search can never find it. The parsed value is now stored, which
also lowercases uppercase ids and adds the dashes to unhyphenated ones.
* fix(plugins): parse IPv6 hosts correctly in the websocket allowlist
isHostAllowed cut the host at the last colon, which mangles an IPv6 literal:
"[::1]:8080" became "[::1]" and "[::1]" became "[:". A plugin manifest could
therefore never allow an IPv6 host. It now uses net.SplitHostPort, falling back to
unwrapping the brackets when there is no port.
* fix(server): serve pprof profiles when a BaseURL is configured
net/http/pprof's Index resolves the profile name by trimming "/debug/pprof/" from
the raw request path, which never matches once MountRouter prepends the BasePath.
Requests for any profile without an explicit chi route fell through to the index
page, returning HTML with a 200 instead of the profile. The handler now strips the
BasePath first.
* test(scanner): run the goroutine leak check unconditionally
The scanner suite's goleak check only ran when the GOLEAK env var was set, so it
never ran in CI and could not catch a regression. It passes with the existing
ignore list, verified over repeated runs, so the gate is removed.
* fix(server): close the background image body on a non-200 response
serveImage returned early on an unexpected status code without closing the response
body, pinning the connection until the 5s client timeout. The nolint:bodyclose
above the request suppressed the linter that would have caught it, and its
justification only holds on the success path, where the body is handed to the
CachedStream wrapper.
* test(scanner): repair BenchmarkScan so it can actually run
The benchmark failed three ways before reaching its first iteration: it reused a
shared temp DB and tried to repoint the default library, it never loaded the config
defaults so the scanner got a concurrency of 0, and it lacked the notify ignore that
the suite already carries. tests.Init now takes a testing.TB so a benchmark can load
the test config the same way the suites do.
* refactor(artwork): drop the unused sourceFunc Stringer
sourceFunc.String derived a label from the closure's symbol name via reflection, but
nothing called it: the trace output builds its candidate labels from explicit strings.
Whole-program analysis confirms it is unreachable, and dropping it removes a
reflection-based dependency on compiler closure-naming details.
* refactor(plugins): reuse extractHostname in the websocket allowlist
The IPv6 host parsing added for isHostAllowed duplicated extractHostname, which
already lives in the same package and backs the HTTP client's identical allowlist
check. Two copies of a security-relevant parser can drift, so the websocket service
now calls the existing helper. The port-stripping specs move into the URL Validation
block that already covered them.
* perf(scanner): bound the tag truncation trim to a partial rune
The trim loop dropped every trailing byte that failed to decode, so a value ending
in a long run of invalid bytes was walked one byte at a time: a 1 MiB lyrics tag
measured 2.58ms against 45ns for a normal cut. A partial rune is at most 3 trailing
bytes, so the loop is capped there, which also stops it consuming a pre-existing
invalid run.
* test: tighten the tests added with the Go 1.27 bugfixes
Drop the testItem stub in favour of the package's own cacheKey, register the pprof
test profile once at package scope, and replace the hand-rolled goroutine settle
loop with Eventually. Also corrects a comment that credited a TestMain the scanner
suite does not have.
* test(scanner): ignore notify's nonrecursive-tree goroutines on Linux
The goroutine leak check only ignored the recursive tree (macOS/FSEvents).
Linux CI uses inotify, whose nonrecursive tree leaks dispatch and internal
goroutines after Stop(), failing the check.
* fix(scanner): avoid a truncation panic when MaxLength is 1 or 2
A value of only UTF-8 continuation bytes drained the partial-rune loop to
empty, then sliced value[:-1] and panicked. Break when DecodeLastRune returns
size 0 (empty string) by testing size != 1 instead of size > 1.
* fix: address Codex review on the pprof base path and scan benchmark
- profilerHandler: treat a root BasePath ("/") as no prefix, so http.StripPrefix
keeps the leading slash chi needs; without this the profiler 404s when BaseURL
is "/". Cover the root case in the test.
- BenchmarkScan: make it run regardless of test/benchmark ordering. Add
singleton.DeleteInstance so a fresh DB is opened after TestScanner closes the
shared one, guard driver registration with sync.Once so the rebuild does not
re-Register, and ignore the Ginkgo interrupt-handler and Linux notify
goroutines the preceding suite leaves behind.
* fix: address Codex round 2 on BasePath trailing slash and benchmark DB cleanup
- profilerHandler: trim all trailing slashes (TrimRight), not just a bare "/", so
a BaseURL like "/music/" strips correctly instead of 404ing. Cover it in the test.
- BenchmarkScan: keep and defer db.Init's closer so the DB is closed before
b.TempDir cleanup, which otherwise cannot delete the open SQLite/WAL files on Windows.
636 lines
18 KiB
Go
636 lines
18 KiB
Go
//go:build !windows
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("CacheService", func() {
|
|
var service *cacheServiceImpl
|
|
var ctx context.Context
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
service = newCacheService("test_plugin")
|
|
})
|
|
|
|
AfterEach(func() {
|
|
if service != nil {
|
|
service.Close()
|
|
}
|
|
})
|
|
|
|
Describe("getTTL", func() {
|
|
It("returns default TTL when seconds is 0", func() {
|
|
ttl := service.getTTL(0)
|
|
Expect(ttl).To(Equal(defaultCacheTTL))
|
|
})
|
|
|
|
It("returns default TTL when seconds is negative", func() {
|
|
ttl := service.getTTL(-10)
|
|
Expect(ttl).To(Equal(defaultCacheTTL))
|
|
})
|
|
|
|
It("returns correct duration when seconds is positive", func() {
|
|
ttl := service.getTTL(60)
|
|
Expect(ttl).To(Equal(time.Minute))
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Isolation", func() {
|
|
It("isolates keys between plugins", func() {
|
|
service1 := newCacheService("plugin1")
|
|
defer service1.Close()
|
|
service2 := newCacheService("plugin2")
|
|
defer service2.Close()
|
|
|
|
// Both plugins set same key
|
|
err := service1.SetString(ctx, "shared", "value1", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = service2.SetString(ctx, "shared", "value2", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Each plugin should get their own value
|
|
val1, exists1, err := service1.GetString(ctx, "shared")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists1).To(BeTrue())
|
|
Expect(val1).To(Equal("value1"))
|
|
|
|
val2, exists2, err := service2.GetString(ctx, "shared")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists2).To(BeTrue())
|
|
Expect(val2).To(Equal("value2"))
|
|
})
|
|
})
|
|
|
|
Describe("String Operations", func() {
|
|
It("sets and gets a string value", func() {
|
|
err := service.SetString(ctx, "string_key", "test_value", 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
value, exists, err := service.GetString(ctx, "string_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal("test_value"))
|
|
})
|
|
|
|
It("returns not exists for missing key", func() {
|
|
value, exists, err := service.GetString(ctx, "missing_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(""))
|
|
})
|
|
})
|
|
|
|
Describe("Integer Operations", func() {
|
|
It("sets and gets an integer value", func() {
|
|
err := service.SetInt(ctx, "int_key", 42, 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
value, exists, err := service.GetInt(ctx, "int_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal(int64(42)))
|
|
})
|
|
|
|
It("returns not exists for missing key", func() {
|
|
value, exists, err := service.GetInt(ctx, "missing_int_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(int64(0)))
|
|
})
|
|
})
|
|
|
|
Describe("Float Operations", func() {
|
|
It("sets and gets a float value", func() {
|
|
err := service.SetFloat(ctx, "float_key", 3.14, 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
value, exists, err := service.GetFloat(ctx, "float_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal(3.14))
|
|
})
|
|
|
|
It("returns not exists for missing key", func() {
|
|
value, exists, err := service.GetFloat(ctx, "missing_float_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(float64(0)))
|
|
})
|
|
})
|
|
|
|
Describe("Bytes Operations", func() {
|
|
It("sets and gets a bytes value", func() {
|
|
byteData := []byte("hello world")
|
|
err := service.SetBytes(ctx, "bytes_key", byteData, 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
value, exists, err := service.GetBytes(ctx, "bytes_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
Expect(value).To(Equal(byteData))
|
|
})
|
|
|
|
It("returns not exists for missing key", func() {
|
|
value, exists, err := service.GetBytes(ctx, "missing_bytes_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("Type mismatch handling", func() {
|
|
It("returns not exists when type doesn't match the getter", func() {
|
|
// Set string
|
|
err := service.SetString(ctx, "mixed_key", "string value", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Try to get as int
|
|
value, exists, err := service.GetInt(ctx, "mixed_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(int64(0)))
|
|
})
|
|
|
|
It("returns not exists when getting string as float", func() {
|
|
err := service.SetString(ctx, "str_as_float", "not a float", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
value, exists, err := service.GetFloat(ctx, "str_as_float")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(Equal(float64(0)))
|
|
})
|
|
|
|
It("returns not exists when getting int as bytes", func() {
|
|
err := service.SetInt(ctx, "int_as_bytes", 123, 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
value, exists, err := service.GetBytes(ctx, "int_as_bytes")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
Expect(value).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("Has Operation", func() {
|
|
It("returns true for existing key", func() {
|
|
err := service.SetString(ctx, "existing_key", "exists", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
exists, err := service.Has(ctx, "existing_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
})
|
|
|
|
It("returns false for non-existing key", func() {
|
|
exists, err := service.Has(ctx, "non_existing_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("Remove Operation", func() {
|
|
It("removes a value from the cache", func() {
|
|
// Set a value
|
|
err := service.SetString(ctx, "remove_key", "to be removed", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify it exists
|
|
exists, err := service.Has(ctx, "remove_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
|
|
// Remove it
|
|
err = service.Remove(ctx, "remove_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify it's gone
|
|
exists, err = service.Has(ctx, "remove_key")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeFalse())
|
|
})
|
|
|
|
It("does not error when removing non-existing key", func() {
|
|
err := service.Remove(ctx, "never_existed")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Describe("TTL Behavior", func() {
|
|
It("uses default TTL when 0 is provided", func() {
|
|
err := service.SetString(ctx, "default_ttl", "value", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Value should exist immediately
|
|
exists, err := service.Has(ctx, "default_ttl")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
})
|
|
|
|
It("uses custom TTL when provided", func() {
|
|
err := service.SetString(ctx, "custom_ttl", "value", 300)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Value should exist immediately
|
|
exists, err := service.Has(ctx, "custom_ttl")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(exists).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Describe("Close", func() {
|
|
It("removes all cache entries for the plugin", func() {
|
|
// Use a dedicated service for this test
|
|
closeService := newCacheService("close_test_plugin")
|
|
|
|
// Set multiple values
|
|
err := closeService.SetString(ctx, "key1", "value1", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = closeService.SetInt(ctx, "key2", 42, 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = closeService.SetFloat(ctx, "key3", 3.14, 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify they exist
|
|
exists, _ := closeService.Has(ctx, "key1")
|
|
Expect(exists).To(BeTrue())
|
|
exists, _ = closeService.Has(ctx, "key2")
|
|
Expect(exists).To(BeTrue())
|
|
exists, _ = closeService.Has(ctx, "key3")
|
|
Expect(exists).To(BeTrue())
|
|
|
|
// Close the service
|
|
err = closeService.Close()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// All entries should be gone
|
|
exists, _ = closeService.Has(ctx, "key1")
|
|
Expect(exists).To(BeFalse())
|
|
exists, _ = closeService.Has(ctx, "key2")
|
|
Expect(exists).To(BeFalse())
|
|
exists, _ = closeService.Has(ctx, "key3")
|
|
Expect(exists).To(BeFalse())
|
|
})
|
|
|
|
It("does not affect other plugins' cache entries", func() {
|
|
// Create two services for different plugins
|
|
service1 := newCacheService("plugin_close_test1")
|
|
service2 := newCacheService("plugin_close_test2")
|
|
defer service2.Close()
|
|
|
|
// Set values for both plugins
|
|
err := service1.SetString(ctx, "key", "value1", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = service2.SetString(ctx, "key", "value2", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Close only service1
|
|
err = service1.Close()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// service1's key should be gone
|
|
exists, _ := service1.Has(ctx, "key")
|
|
Expect(exists).To(BeFalse())
|
|
|
|
// service2's key should still exist
|
|
exists, _ = service2.Has(ctx, "key")
|
|
Expect(exists).To(BeTrue())
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("CacheService Integration", Ordered, func() {
|
|
var (
|
|
manager *Manager
|
|
tmpDir string
|
|
)
|
|
|
|
BeforeAll(func() {
|
|
var err error
|
|
tmpDir, err = os.MkdirTemp("", "cache-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Copy the test-cache-plugin
|
|
srcPath := filepath.Join(testdataDir, "test-cache-plugin"+PackageExtension)
|
|
destPath := filepath.Join(tmpDir, "test-cache-plugin"+PackageExtension)
|
|
data, err := os.ReadFile(srcPath)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
err = os.WriteFile(destPath, data, 0600)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Compute SHA256 for the plugin
|
|
hash := sha256.Sum256(data)
|
|
hashHex := hex.EncodeToString(hash[:])
|
|
|
|
// Setup config
|
|
DeferCleanup(configtest.SetupConfig())
|
|
conf.Server.Plugins.Enabled = true
|
|
conf.Server.Plugins.Folder = conf.NewDir(tmpDir)
|
|
conf.Server.Plugins.AutoReload = false
|
|
|
|
// Setup mock DataStore with pre-enabled plugin
|
|
mockPluginRepo := tests.CreateMockPluginRepo()
|
|
mockPluginRepo.Permitted = true
|
|
mockPluginRepo.SetData(model.Plugins{{
|
|
ID: "test-cache-plugin",
|
|
Path: destPath,
|
|
SHA256: hashHex,
|
|
Enabled: true,
|
|
}})
|
|
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
|
|
|
// Create and start manager
|
|
manager = &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
ds: dataStore,
|
|
subsonicRouter: http.NotFoundHandler(),
|
|
}
|
|
err = manager.Start(GinkgoT().Context())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
DeferCleanup(func() {
|
|
_ = manager.Stop()
|
|
_ = os.RemoveAll(tmpDir)
|
|
})
|
|
})
|
|
|
|
Describe("Plugin Loading", func() {
|
|
It("should load plugin with cache permission", func() {
|
|
manager.mu.RLock()
|
|
p, ok := manager.plugins["test-cache-plugin"]
|
|
manager.mu.RUnlock()
|
|
Expect(ok).To(BeTrue())
|
|
Expect(p.manifest.Permissions).ToNot(BeNil())
|
|
Expect(p.manifest.Permissions.Cache).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Describe("Cache Operations via Plugin", func() {
|
|
type testCacheInput struct {
|
|
Operation string `json:"operation"`
|
|
Key string `json:"key"`
|
|
StringVal string `json:"string_val,omitempty"`
|
|
IntVal int64 `json:"int_val,omitempty"`
|
|
FloatVal float64 `json:"float_val,omitempty"`
|
|
BytesVal []byte `json:"bytes_val,omitempty"`
|
|
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
|
|
}
|
|
type testCacheOutput struct {
|
|
StringVal string `json:"string_val,omitempty"`
|
|
IntVal int64 `json:"int_val,omitempty"`
|
|
FloatVal float64 `json:"float_val,omitempty"`
|
|
BytesVal []byte `json:"bytes_val,omitempty"`
|
|
Exists bool `json:"exists,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
callTestCache := func(ctx context.Context, input testCacheInput) (*testCacheOutput, error) {
|
|
manager.mu.RLock()
|
|
p := manager.plugins["test-cache-plugin"]
|
|
manager.mu.RUnlock()
|
|
|
|
instance, err := p.instance(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer instance.Close(ctx)
|
|
|
|
inputBytes, _ := json.Marshal(input)
|
|
_, outputBytes, err := instance.Call("nd_test_cache", inputBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var output testCacheOutput
|
|
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
|
return nil, err
|
|
}
|
|
if output.Error != nil {
|
|
return nil, errors.New(*output.Error)
|
|
}
|
|
return &output, nil
|
|
}
|
|
|
|
It("should set and get string value", func() {
|
|
ctx := GinkgoT().Context()
|
|
|
|
// Set string
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_string",
|
|
Key: "test_string",
|
|
StringVal: "hello world",
|
|
TTLSeconds: 300,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get string
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "get_string",
|
|
Key: "test_string",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
Expect(output.StringVal).To(Equal("hello world"))
|
|
})
|
|
|
|
It("should set and get integer value", func() {
|
|
ctx := GinkgoT().Context()
|
|
|
|
// Set int
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_int",
|
|
Key: "test_int",
|
|
IntVal: 42,
|
|
TTLSeconds: 300,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get int
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "get_int",
|
|
Key: "test_int",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
Expect(output.IntVal).To(Equal(int64(42)))
|
|
})
|
|
|
|
It("should set and get float value", func() {
|
|
ctx := GinkgoT().Context()
|
|
|
|
// Set float
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_float",
|
|
Key: "test_float",
|
|
FloatVal: 3.14159,
|
|
TTLSeconds: 300,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get float
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "get_float",
|
|
Key: "test_float",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
Expect(output.FloatVal).To(Equal(3.14159))
|
|
})
|
|
|
|
It("should set and get bytes value", func() {
|
|
ctx := GinkgoT().Context()
|
|
testBytes := []byte{0x01, 0x02, 0x03, 0x04}
|
|
|
|
// Set bytes
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_bytes",
|
|
Key: "test_bytes",
|
|
BytesVal: testBytes,
|
|
TTLSeconds: 300,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get bytes
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "get_bytes",
|
|
Key: "test_bytes",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
Expect(output.BytesVal).To(Equal(testBytes))
|
|
})
|
|
|
|
It("should handle binary data with null bytes through WASM", func() {
|
|
ctx := GinkgoT().Context()
|
|
|
|
// Binary data with null bytes, high bytes, and other edge cases
|
|
binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x80, 0x7F}
|
|
|
|
// Set binary bytes
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_bytes",
|
|
Key: "binary_test",
|
|
BytesVal: binaryData,
|
|
TTLSeconds: 300,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Get binary bytes and verify exact match
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "get_bytes",
|
|
Key: "binary_test",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
Expect(output.BytesVal).To(Equal(binaryData))
|
|
})
|
|
|
|
It("should check if key exists", func() {
|
|
ctx := GinkgoT().Context()
|
|
|
|
// Set a value
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_string",
|
|
Key: "exists_test",
|
|
StringVal: "value",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Check has
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "has",
|
|
Key: "exists_test",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeTrue())
|
|
|
|
// Check non-existent
|
|
output, err = callTestCache(ctx, testCacheInput{
|
|
Operation: "has",
|
|
Key: "nonexistent",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeFalse())
|
|
})
|
|
|
|
It("should remove a key", func() {
|
|
ctx := GinkgoT().Context()
|
|
|
|
// Set a value
|
|
_, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "set_string",
|
|
Key: "remove_test",
|
|
StringVal: "value",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Remove it
|
|
_, err = callTestCache(ctx, testCacheInput{
|
|
Operation: "remove",
|
|
Key: "remove_test",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Verify it's gone
|
|
output, err := callTestCache(ctx, testCacheInput{
|
|
Operation: "has",
|
|
Key: "remove_test",
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(output.Exists).To(BeFalse())
|
|
})
|
|
})
|
|
})
|
|
|
|
var _ = Describe("newCacheService", func() {
|
|
// The suite above leaves goroutines winding down, so settle before sampling.
|
|
settledBaseline := func() int {
|
|
var n int
|
|
Eventually(func() int {
|
|
runtime.GC()
|
|
prev := n
|
|
n = runtime.NumGoroutine()
|
|
return n - prev
|
|
}).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero())
|
|
return n
|
|
}
|
|
|
|
It("stops the janitor goroutine once the service is unreachable", func() {
|
|
const numServices = 5
|
|
baseline := settledBaseline()
|
|
|
|
func() {
|
|
services := make([]*cacheServiceImpl, 0, numServices)
|
|
for i := range numServices {
|
|
services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i)))
|
|
}
|
|
Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices),
|
|
"expected one janitor goroutine per cache service")
|
|
}()
|
|
|
|
Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }).
|
|
WithTimeout(10*time.Second).WithPolling(20*time.Millisecond).
|
|
Should(BeNumerically("<=", baseline), "janitor goroutines leaked")
|
|
})
|
|
})
|