fuse: reset treeCache on snapshot reload to fix stale latest symlink (#21873)

The treeCache in SnapshotsDir was never cleared when snapshots were
reloaded. This caused the "latest" symlink to keep pointing to the
previous snapshot even after new snapshots were added.

Add a generation counter to SnapshotsDirStructure that is incremented
whenever the directory structure is rebuilt (in makeDirs). The
treeCache checks this generation on each lookup and resets itself
when the generation changes, ensuring cached nodes (including symlinks)
are refreshed after a snapshot reload.
This commit is contained in:
Ricardo Sawir
2026-06-22 18:31:51 +00:00
committed by GitHub
parent d30d6b6281
commit 55e335ec6c
7 changed files with 153 additions and 11 deletions
+10
View File
@@ -0,0 +1,10 @@
Bugfix: Update `mount` latest symlink after snapshot reload
When `restic mount` was kept running while new snapshots were
created, the new snapshots appeared in the mountpoint, but the `latest`
symlink could still point to the previously latest snapshot. Restic now
invalidates the cached snapshot directory entries after a snapshot reload so
that `latest` points to the newest snapshot.
https://github.com/restic/restic/issues/5722
https://github.com/restic/restic/pull/21873
+1 -1
View File
@@ -225,7 +225,7 @@ func (d *dir) Lookup(ctx context.Context, name string) (fs.Node, error) {
return nil, err return nil, err
} }
return d.cache.lookupOrCreate(name, func(forget forgetFn) (fs.Node, error) { return d.cache.lookupOrCreate(name, -1, func(forget forgetFn) (fs.Node, error) {
node, ok := d.items[name] node, ok := d.items[name]
if !ok { if !ok {
debug.Log(" Lookup(%v) -> not found", name) debug.Log(" Lookup(%v) -> not found", name)
+33
View File
@@ -247,6 +247,39 @@ func TestStableNodeObjects(t *testing.T) {
testStableLookup(t, dir, "file-2") testStableLookup(t, dir, "file-2")
} }
func TestSnapshotsDirLatestSymlinkUpdatesAfterReload(t *testing.T) {
repo := repository.TestRepository(t)
timeTemplate := "2006-01-02T15:04:05"
firstTime, err := time.Parse(time.RFC3339, "2017-01-24T10:42:56Z")
rtest.OK(t, err)
secondTime := firstTime.Add(time.Hour)
data.TestCreateSnapshot(t, repo, firstTime, 0)
root := NewRoot(repo, Config{
TimeTemplate: timeTemplate,
PathTemplates: []string{"snapshots/%T"},
})
snapshotsDir := testStableLookup(t, root, "snapshots")
rtest.Equals(t, firstTime.Format(timeTemplate), readLatestTarget(t, snapshotsDir))
data.TestCreateSnapshot(t, repo, secondTime, 0)
root.SnapshotsDir.dirStruct.lastCheck = time.Now().Add(-2 * minSnapshotsReloadTime)
rtest.Equals(t, secondTime.Format(timeTemplate), readLatestTarget(t, snapshotsDir))
}
func readLatestTarget(t testing.TB, node fs.Node) string {
t.Helper()
latest, err := node.(fs.NodeStringLookuper).Lookup(context.TODO(), "latest")
rtest.OK(t, err)
target, err := latest.(fs.NodeReadlinker).Readlink(context.TODO(), nil)
rtest.OK(t, err)
return target
}
// Test reporting of fuse.Attr.Blocks in multiples of 512. // Test reporting of fuse.Attr.Blocks in multiples of 512.
func TestBlocks(t *testing.T) { func TestBlocks(t *testing.T) {
root := &Root{} root := &Root{}
+3 -3
View File
@@ -61,7 +61,7 @@ func (d *SnapshotsDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
debug.Log("ReadDirAll()") debug.Log("ReadDirAll()")
// update snapshots // update snapshots
meta, err := d.dirStruct.UpdatePrefix(ctx, d.prefix) meta, _, err := d.dirStruct.UpdatePrefix(ctx, d.prefix)
if err != nil { if err != nil {
return nil, unwrapCtxCanceled(err) return nil, unwrapCtxCanceled(err)
} else if meta == nil { } else if meta == nil {
@@ -104,14 +104,14 @@ func (d *SnapshotsDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
func (d *SnapshotsDir) Lookup(ctx context.Context, name string) (fs.Node, error) { func (d *SnapshotsDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
debug.Log("Lookup(%s)", name) debug.Log("Lookup(%s)", name)
meta, err := d.dirStruct.UpdatePrefix(ctx, d.prefix) meta, gen, err := d.dirStruct.UpdatePrefix(ctx, d.prefix)
if err != nil { if err != nil {
return nil, unwrapCtxCanceled(err) return nil, unwrapCtxCanceled(err)
} else if meta == nil { } else if meta == nil {
return nil, syscall.ENOENT return nil, syscall.ENOENT
} }
return d.cache.lookupOrCreate(name, func(forget forgetFn) (fs.Node, error) { return d.cache.lookupOrCreate(name, gen, func(forget forgetFn) (fs.Node, error) {
entry := meta.names[name] entry := meta.names[name]
if entry == nil { if entry == nil {
return nil, syscall.ENOENT return nil, syscall.ENOENT
+8 -3
View File
@@ -43,6 +43,10 @@ type SnapshotsDirStructure struct {
hash [sha256.Size]byte // Hash at last check. hash [sha256.Size]byte // Hash at last check.
lastCheck time.Time lastCheck time.Time
// generation is incremented whenever the directory structure is rebuilt.
// It allows treeCache instances to detect stale entries and reset themselves.
generation int64
} }
// NewSnapshotsDirStructure returns a new directory structure for snapshots. // NewSnapshotsDirStructure returns a new directory structure for snapshots.
@@ -281,6 +285,7 @@ func (d *SnapshotsDirStructure) makeDirs(snapshots data.Snapshots) {
} }
d.entries = entries d.entries = entries
d.generation++
} }
const minSnapshotsReloadTime = 60 * time.Second const minSnapshotsReloadTime = 60 * time.Second
@@ -338,13 +343,13 @@ func (d *SnapshotsDirStructure) updateSnapshots(ctx context.Context) error {
return nil return nil
} }
func (d *SnapshotsDirStructure) UpdatePrefix(ctx context.Context, prefix string) (*MetaDirData, error) { func (d *SnapshotsDirStructure) UpdatePrefix(ctx context.Context, prefix string) (*MetaDirData, int64, error) {
err := d.updateSnapshots(ctx) err := d.updateSnapshots(ctx)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
d.mutex.Lock() d.mutex.Lock()
defer d.mutex.Unlock() defer d.mutex.Unlock()
return d.entries[prefix], nil return d.entries[prefix], d.generation, nil
} }
+16 -4
View File
@@ -5,12 +5,15 @@ package fuse
import ( import (
"sync" "sync"
"github.com/restic/restic/internal/debug"
"github.com/anacrolix/fuse/fs" "github.com/anacrolix/fuse/fs"
) )
type treeCache struct { type treeCache struct {
nodes map[string]fs.Node nodes map[string]fs.Node
m sync.Mutex m sync.Mutex
generation int64
} }
type forgetFn func() type forgetFn func()
@@ -21,19 +24,28 @@ func newTreeCache() *treeCache {
} }
} }
func (t *treeCache) lookupOrCreate(name string, create func(forget forgetFn) (fs.Node, error)) (fs.Node, error) { func (t *treeCache) lookupOrCreate(name string, generation int64, create func(forget forgetFn) (fs.Node, error)) (fs.Node, error) {
t.m.Lock() t.m.Lock()
defer t.m.Unlock() defer t.m.Unlock()
if generation >= 0 && generation != t.generation {
debug.Log("treeCache generation changed %d -> %d, resetting cache", t.generation, generation)
t.nodes = make(map[string]fs.Node)
t.generation = generation
}
if node, ok := t.nodes[name]; ok { if node, ok := t.nodes[name]; ok {
return node, nil return node, nil
} }
cacheGeneration := t.generation
node, err := create(func() { node, err := create(func() {
t.m.Lock() t.m.Lock()
defer t.m.Unlock() defer t.m.Unlock()
delete(t.nodes, name) if t.generation == cacheGeneration {
delete(t.nodes, name)
}
}) })
if err != nil { if err != nil {
return nil, err return nil, err
+82
View File
@@ -0,0 +1,82 @@
//go:build darwin || freebsd || linux
package fuse
import (
"context"
"testing"
"github.com/anacrolix/fuse"
"github.com/anacrolix/fuse/fs"
"github.com/restic/restic/internal/test"
)
type cacheTestNode struct {
id int
}
func (n cacheTestNode) Attr(context.Context, *fuse.Attr) error {
return nil
}
func TestTreeCacheGeneration(t *testing.T) {
cache := newTreeCache()
created := 0
create := func(forgetFn) (fs.Node, error) {
created++
return cacheTestNode{id: created}, nil
}
node1, err := cache.lookupOrCreate("node", 1, create)
test.OK(t, err)
node2, err := cache.lookupOrCreate("node", 1, create)
test.OK(t, err)
test.Assert(t, node1 == node2, "lookup should reuse cached node")
test.Equals(t, 1, created)
node3, err := cache.lookupOrCreate("node", 2, create)
test.OK(t, err)
test.Assert(t, node1 != node3, "lookup should recreate node after generation change")
test.Equals(t, 2, created)
node4, err := cache.lookupOrCreate("node", -1, create)
test.OK(t, err)
test.Assert(t, node3 == node4, "negative generation should not reset cache")
test.Equals(t, 2, created)
node5, err := cache.lookupOrCreate("node", 3, create)
test.OK(t, err)
test.Assert(t, node4 != node5, "lookup should still track later generation changes")
test.Equals(t, 3, created)
}
func TestTreeCacheForgetOnlyRemovesSameGeneration(t *testing.T) {
cache := newTreeCache()
created := 0
var forgets []forgetFn
create := func(forget forgetFn) (fs.Node, error) {
created++
forgets = append(forgets, forget)
return cacheTestNode{id: created}, nil
}
node1, err := cache.lookupOrCreate("node", 1, create)
test.OK(t, err)
node2, err := cache.lookupOrCreate("node", 2, create)
test.OK(t, err)
test.Assert(t, node1 != node2, "lookup should recreate node after generation change")
test.Equals(t, 2, created)
forgets[0]()
node3, err := cache.lookupOrCreate("node", 2, create)
test.OK(t, err)
test.Assert(t, node2 == node3, "forget from an old generation must not remove the current node")
test.Equals(t, 2, created)
forgets[1]()
node4, err := cache.lookupOrCreate("node", 2, create)
test.OK(t, err)
test.Assert(t, node3 != node4, "forget from the current generation should remove the cached node")
test.Equals(t, 3, created)
}