Files
rclone/lib/cache/cache_test.go
T
Nick Craig-Wood 2f84013eec lib/cache: add SetCanExpire to keep entries which are still busy
An entry is expired when it hasn't been used for a while, but the user
of the cache may know it is still doing something in the background.
SetCanExpire sets a function which is asked before expiring an entry
and can keep it in the cache until it is ready to go.

The function is called without the cache locked as it may take a
while, so an entry which is used in the meantime isn't expired.

(cherry picked from commit 49d8566ec5)
2026-10-08 17:17:50 +01:00

447 lines
8.8 KiB
Go

package cache
import (
"errors"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
called = 0
errSentinel = errors.New("an error")
errCached = errors.New("a cached error")
)
func setup(t *testing.T) (*Cache, CreateFunc) {
called = 0
create := func(path string) (any, bool, error) {
assert.Equal(t, 0, called)
called++
switch path {
case "/":
return "/", true, nil
case "/file.txt":
return "/file.txt", true, errCached
case "/error":
return nil, false, errSentinel
case "/err":
return nil, false, errSentinel
}
panic(fmt.Sprintf("Unknown path %q", path))
}
c := New()
return c, create
}
func TestGet(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
f, err := c.Get("/", create)
require.NoError(t, err)
assert.Equal(t, 1, len(c.cache))
f2, err := c.Get("/", create)
require.NoError(t, err)
assert.Equal(t, f, f2)
}
func TestGetFile(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
f, err := c.Get("/file.txt", create)
require.Equal(t, errCached, err)
assert.Equal(t, 1, len(c.cache))
f2, err := c.Get("/file.txt", create)
require.Equal(t, errCached, err)
assert.Equal(t, f, f2)
}
func TestGetError(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
f, err := c.Get("/error", create)
require.Equal(t, errSentinel, err)
require.Equal(t, nil, f)
assert.Equal(t, 0, len(c.cache))
}
func TestPutErr(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
c.PutErr("/alien", "slime", errSentinel)
assert.Equal(t, 1, len(c.cache))
fNew, err := c.Get("/alien", create)
require.Equal(t, errSentinel, err)
require.Equal(t, "slime", fNew)
assert.Equal(t, 1, len(c.cache))
}
func TestPut(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
c.Put("/alien", "slime")
assert.Equal(t, 1, len(c.cache))
fNew, err := c.Get("/alien", create)
require.NoError(t, err)
require.Equal(t, "slime", fNew)
assert.Equal(t, 1, len(c.cache))
}
func TestCacheExpire(t *testing.T) {
c, create := setup(t)
c.SetExpireInterval(time.Millisecond)
assert.Equal(t, false, c.expireRunning)
_, err := c.Get("/", create)
require.NoError(t, err)
c.mu.Lock()
entry := c.cache["/"]
assert.Equal(t, 1, len(c.cache))
c.mu.Unlock()
c.cacheExpire()
c.mu.Lock()
assert.Equal(t, 1, len(c.cache))
entry.lastUsed = time.Now().Add(-c.expireDuration - 60*time.Second)
assert.Equal(t, true, c.expireRunning)
c.mu.Unlock()
time.Sleep(250 * time.Millisecond)
c.mu.Lock()
assert.Equal(t, false, c.expireRunning)
assert.Equal(t, 0, len(c.cache))
c.mu.Unlock()
}
func TestCacheNoExpire(t *testing.T) {
c, create := setup(t)
assert.False(t, c.noCache())
c.SetExpireDuration(0)
assert.Equal(t, false, c.expireRunning)
assert.True(t, c.noCache())
f, err := c.Get("/", create)
require.NoError(t, err)
require.NotNil(t, f)
c.mu.Lock()
assert.Equal(t, 0, len(c.cache))
c.mu.Unlock()
c.Put("/alien", "slime")
c.mu.Lock()
assert.Equal(t, 0, len(c.cache))
c.mu.Unlock()
}
func TestCachePin(t *testing.T) {
c, create := setup(t)
_, err := c.Get("/", create)
require.NoError(t, err)
// Pin a nonexistent item to show nothing happens
c.Pin("notfound")
c.mu.Lock()
entry := c.cache["/"]
assert.Equal(t, 1, len(c.cache))
c.mu.Unlock()
c.cacheExpire()
c.mu.Lock()
assert.Equal(t, 1, len(c.cache))
c.mu.Unlock()
// Pin the entry and check it does not get expired
c.Pin("/")
// Reset last used to make the item expirable
c.mu.Lock()
entry.lastUsed = time.Now().Add(-c.expireDuration - 60*time.Second)
c.mu.Unlock()
c.cacheExpire()
c.mu.Lock()
assert.Equal(t, 1, len(c.cache))
c.mu.Unlock()
// Unpin the entry and check it does get expired now
c.Unpin("/")
// Reset last used
c.mu.Lock()
entry.lastUsed = time.Now().Add(-c.expireDuration - 60*time.Second)
c.mu.Unlock()
c.cacheExpire()
c.mu.Lock()
assert.Equal(t, 0, len(c.cache))
c.mu.Unlock()
}
func TestClear(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
_, err := c.Get("/", create)
require.NoError(t, err)
assert.Equal(t, 1, len(c.cache))
c.Clear()
assert.Equal(t, 0, len(c.cache))
}
func TestEntries(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, c.Entries())
_, err := c.Get("/", create)
require.NoError(t, err)
assert.Equal(t, 1, c.Entries())
c.Clear()
assert.Equal(t, 0, c.Entries())
}
func TestGetMaybe(t *testing.T) {
c, create := setup(t)
value, found := c.GetMaybe("/")
assert.Equal(t, false, found)
assert.Nil(t, value)
f, err := c.Get("/", create)
require.NoError(t, err)
value, found = c.GetMaybe("/")
assert.Equal(t, true, found)
assert.Equal(t, f, value)
c.Clear()
value, found = c.GetMaybe("/")
assert.Equal(t, false, found)
assert.Nil(t, value)
}
func TestDelete(t *testing.T) {
c, create := setup(t)
assert.Equal(t, 0, len(c.cache))
_, err := c.Get("/", create)
require.NoError(t, err)
assert.Equal(t, 1, len(c.cache))
assert.Equal(t, false, c.Delete("notfound"))
assert.Equal(t, 1, len(c.cache))
assert.Equal(t, true, c.Delete("/"))
assert.Equal(t, 0, len(c.cache))
assert.Equal(t, false, c.Delete("/"))
assert.Equal(t, 0, len(c.cache))
}
func TestDeletePrefix(t *testing.T) {
create := func(path string) (any, bool, error) {
return path, true, nil
}
c := New()
_, err := c.Get("remote:path", create)
require.NoError(t, err)
_, err = c.Get("remote:path2", create)
require.NoError(t, err)
_, err = c.Get("remote:", create)
require.NoError(t, err)
_, err = c.Get("remote", create)
require.NoError(t, err)
assert.Equal(t, 4, len(c.cache))
assert.Equal(t, 3, c.DeletePrefix("remote:"))
assert.Equal(t, 1, len(c.cache))
assert.Equal(t, 1, c.DeletePrefix(""))
assert.Equal(t, 0, len(c.cache))
assert.Equal(t, 0, c.DeletePrefix(""))
assert.Equal(t, 0, len(c.cache))
}
func TestCacheRename(t *testing.T) {
c := New()
create := func(path string) (any, bool, error) {
return path, true, nil
}
existing1, err := c.Get("existing1", create)
require.NoError(t, err)
_, err = c.Get("existing2", create)
require.NoError(t, err)
assert.Equal(t, 2, c.Entries())
// rename to nonexistent
value, found := c.Rename("existing1", "EXISTING1")
assert.Equal(t, true, found)
assert.Equal(t, existing1, value)
assert.Equal(t, 2, c.Entries())
// rename to existent and check existing value is returned
value, found = c.Rename("existing2", "EXISTING1")
assert.Equal(t, true, found)
assert.Equal(t, existing1, value)
assert.Equal(t, 1, c.Entries())
// rename nonexistent
value, found = c.Rename("notfound", "NOTFOUND")
assert.Equal(t, false, found)
assert.Nil(t, value)
assert.Equal(t, 1, c.Entries())
}
func TestCacheFinalize(t *testing.T) {
c := New()
numCalled := 0
c.SetFinalizer(func(v any) {
numCalled++
})
create := func(path string) (any, bool, error) {
return path, true, nil
}
_, _ = c.Get("ok", create)
assert.Equal(t, 0, numCalled)
c.Clear()
assert.Equal(t, 1, numCalled)
_, _ = c.Get("ok", create)
c.Delete("ok")
assert.Equal(t, 2, numCalled)
_, _ = c.Get("ok", create)
c.DeletePrefix("ok")
assert.Equal(t, 3, numCalled)
_, _ = c.Get("old", create)
_, _ = c.Get("new", create)
c.Rename("old", "new")
assert.Equal(t, 4, numCalled)
c.expireDuration = 1 * time.Millisecond
_, _ = c.Get("ok", create)
time.Sleep(2 * time.Millisecond)
c.cacheExpire() // "ok" and "new" fall out of cache
assert.Equal(t, 6, numCalled)
}
func TestCacheCanExpire(t *testing.T) {
c := New()
finalized := 0
c.SetFinalizer(func(v any) {
finalized++
})
busy := true
c.SetCanExpire(func(v any) bool {
// Must be callable without the lock
assert.Equal(t, 2, c.Entries())
return v != "busy" || !busy
})
create := func(path string) (any, bool, error) {
return path, true, nil
}
c.expireDuration = 1 * time.Millisecond
_, _ = c.Get("busy", create)
_, _ = c.Get("pinned", create)
c.Pin("pinned")
time.Sleep(2 * time.Millisecond)
// Neither the busy nor the pinned entry expire
c.cacheExpire()
assert.Equal(t, 2, c.Entries())
assert.Equal(t, 0, finalized)
assert.Equal(t, true, c.expireRunning)
// The busy entry expires as soon as it is no longer busy
busy = false
c.cacheExpire()
assert.Equal(t, 1, c.Entries())
assert.Equal(t, 1, finalized)
_, found := c.GetMaybe("pinned")
assert.True(t, found)
}
func TestCacheCanExpireUsed(t *testing.T) {
c := New()
finalized := 0
c.SetFinalizer(func(v any) {
finalized++
})
create := func(path string) (any, bool, error) {
return path, true, nil
}
// Use the entry while it is being asked about
c.SetCanExpire(func(v any) bool {
_, _ = c.Get("/", create)
return true
})
c.expireDuration = 1 * time.Second
_, _ = c.Get("/", create)
c.mu.Lock()
c.cache["/"].lastUsed = time.Now().Add(-time.Minute)
c.mu.Unlock()
c.cacheExpire()
assert.Equal(t, 1, c.Entries())
assert.Equal(t, 0, finalized)
}