Skip to content

Commit b9ce141

Browse files
committed
Expand blob prefetch noop cache to N entries
Replace the single-entry LastBlobPrefetch.dat cache with a multi-entry BlobPrefetchCache.dat that stores up to N entries (default 100), keyed by SHA256 hash of (files, folders, hydrate) and storing the commit ID. This avoids redundant diff+download work when users cycle through a small set of prefetch patterns (e.g. 3 different file/folder combos), which previously caused 2/3 of calls to miss the single-entry cache. Changes: - BlobPrefetcher: replace flat 4-key dictionary with hash-keyed cache - BlobPrefetcher.ComputeCacheKey: canonical, order-independent hashing - BlobPrefetcher.SavePrefetchArgs: single-entry eviction when at capacity - PrefetchVerb: read gvfs.prefetchCacheSize config (0=disabled, max 1000) - PrefetchVerb: use BlobPrefetchCache.dat instead of LastBlobPrefetch.dat - 12 unit tests covering key determinism, order independence, cache hit/miss, multi-entry support, and null/empty edge cases Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent 7b0c705 commit b9ce141

3 files changed

Lines changed: 301 additions & 57 deletions

File tree

GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs

Lines changed: 55 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
using System.Collections.Generic;
1010
using System.IO;
1111
using System.Linq;
12+
using System.Security.Cryptography;
13+
using System.Text;
1214
using System.Threading;
1315

1416
namespace GVFS.Common.Prefetch
@@ -32,7 +34,13 @@ public class BlobPrefetcher
3234
private const string AreaPath = nameof(BlobPrefetcher);
3335
private static string pathSeparatorString = Path.DirectorySeparatorChar.ToString();
3436

35-
private FileBasedDictionary<string, string> lastPrefetchArgs;
37+
public const string BlobPrefetchCacheFile = "BlobPrefetchCache.dat";
38+
public const string PrefetchCacheSizeConfigKey = GVFSConstants.GitConfig.GVFSPrefix + "prefetch-cache-size";
39+
public const int DefaultPrefetchCacheSize = 100;
40+
public const int MaxPrefetchCacheSize = 1000;
41+
42+
private FileBasedDictionary<string, string> prefetchCache;
43+
private int maxCacheSize;
3644

3745
public BlobPrefetcher(
3846
ITracer tracer,
@@ -42,7 +50,7 @@ public BlobPrefetcher(
4250
int searchThreadCount,
4351
int downloadThreadCount,
4452
int indexThreadCount)
45-
: this(tracer, enlistment, objectRequestor, null, null, null, chunkSize, searchThreadCount, downloadThreadCount, indexThreadCount)
53+
: this(tracer, enlistment, objectRequestor, null, null, null, DefaultPrefetchCacheSize, chunkSize, searchThreadCount, downloadThreadCount, indexThreadCount)
4654
{
4755
}
4856

@@ -52,7 +60,8 @@ public BlobPrefetcher(
5260
GitObjectsHttpRequestor objectRequestor,
5361
List<string> fileList,
5462
List<string> folderList,
55-
FileBasedDictionary<string, string> lastPrefetchArgs,
63+
FileBasedDictionary<string, string> prefetchCache,
64+
int maxCacheSize,
5665
int chunkSize,
5766
int searchThreadCount,
5867
int downloadThreadCount,
@@ -70,7 +79,8 @@ public BlobPrefetcher(
7079
this.FileList = fileList ?? new List<string>();
7180
this.FolderList = folderList ?? new List<string>();
7281

73-
this.lastPrefetchArgs = lastPrefetchArgs;
82+
this.prefetchCache = prefetchCache;
83+
this.maxCacheSize = maxCacheSize;
7484

7585
// We never want to update config settings for a GVFSEnlistment
7686
this.SkipConfigUpdate = enlistment is GVFSEnlistment;
@@ -127,39 +137,26 @@ public static bool TryLoadFileList(Enlistment enlistment, string filesInput, str
127137

128138
public static bool IsNoopPrefetch(
129139
ITracer tracer,
130-
FileBasedDictionary<string, string> lastPrefetchArgs,
140+
FileBasedDictionary<string, string> prefetchCache,
131141
string commitId,
132142
List<string> files,
133143
List<string> folders,
134144
bool hydrateFilesAfterDownload)
135145
{
136-
if (lastPrefetchArgs != null &&
137-
lastPrefetchArgs.TryGetValue(PrefetchArgs.CommitId, out string lastCommitId) &&
138-
lastPrefetchArgs.TryGetValue(PrefetchArgs.Files, out string lastFilesString) &&
139-
lastPrefetchArgs.TryGetValue(PrefetchArgs.Folders, out string lastFoldersString) &&
140-
lastPrefetchArgs.TryGetValue(PrefetchArgs.Hydrate, out string lastHydrateString))
146+
if (prefetchCache != null)
141147
{
142-
string newFilesString = GVFSJsonOptions.Serialize(files);
143-
string newFoldersString = GVFSJsonOptions.Serialize(folders);
144-
bool isNoop =
145-
commitId == lastCommitId &&
146-
hydrateFilesAfterDownload.ToString() == lastHydrateString &&
147-
newFilesString == lastFilesString &&
148-
newFoldersString == lastFoldersString;
148+
string cacheKey = ComputeCacheKey(files, folders, hydrateFilesAfterDownload);
149+
bool hasEntry = prefetchCache.TryGetValue(cacheKey, out string cachedCommitId);
150+
bool isNoop = hasEntry && commitId == cachedCommitId;
149151

150152
tracer.RelatedEvent(
151153
EventLevel.Informational,
152154
"BlobPrefetcher.IsNoopPrefetch",
153155
new EventMetadata
154156
{
155-
{ "Last" + PrefetchArgs.CommitId, lastCommitId },
156-
{ "Last" + PrefetchArgs.Files, lastFilesString },
157-
{ "Last" + PrefetchArgs.Folders, lastFoldersString },
158-
{ "Last" + PrefetchArgs.Hydrate, lastHydrateString },
159-
{ "New" + PrefetchArgs.CommitId, commitId },
160-
{ "New" + PrefetchArgs.Files, newFilesString },
161-
{ "New" + PrefetchArgs.Folders, newFoldersString },
162-
{ "New" + PrefetchArgs.Hydrate, hydrateFilesAfterDownload.ToString() },
157+
{ "CacheKey", cacheKey },
158+
{ "CachedCommitId", cachedCommitId ?? "(none)" },
159+
{ "NewCommitId", commitId },
163160
{ "Result", isNoop },
164161
});
165162

@@ -580,33 +577,50 @@ private bool IsSymbolicRef(string targetCommitish)
580577

581578
private void SavePrefetchArgs(string targetCommit, bool hydrate)
582579
{
583-
if (this.lastPrefetchArgs != null)
580+
if (this.prefetchCache != null && this.maxCacheSize > 0)
584581
{
585-
this.lastPrefetchArgs.SetValuesAndFlush(
586-
new[]
582+
string cacheKey = ComputeCacheKey(this.FileList, this.FolderList, hydrate);
583+
584+
Dictionary<string, string> allEntries = this.prefetchCache.GetAllKeysAndValues();
585+
if (allEntries.Count >= this.maxCacheSize && !allEntries.ContainsKey(cacheKey))
586+
{
587+
// Evict one arbitrary entry to make room
588+
using (Dictionary<string, string>.Enumerator enumerator = allEntries.GetEnumerator())
587589
{
588-
new KeyValuePair<string, string>(PrefetchArgs.CommitId, targetCommit),
589-
new KeyValuePair<string, string>(PrefetchArgs.Files, GVFSJsonOptions.Serialize(this.FileList)),
590-
new KeyValuePair<string, string>(PrefetchArgs.Folders, GVFSJsonOptions.Serialize(this.FolderList)),
591-
new KeyValuePair<string, string>(PrefetchArgs.Hydrate, hydrate.ToString()),
592-
});
590+
if (enumerator.MoveNext())
591+
{
592+
this.prefetchCache.RemoveAndFlush(enumerator.Current.Key);
593+
}
594+
}
595+
}
596+
597+
this.prefetchCache.SetValueAndFlush(cacheKey, targetCommit);
593598
}
594599
}
595600

601+
internal static string ComputeCacheKey(List<string> files, List<string> folders, bool hydrate)
602+
{
603+
List<string> sortedFiles = new List<string>(files);
604+
sortedFiles.Sort(StringComparer.Ordinal);
605+
606+
List<string> sortedFolders = new List<string>(folders);
607+
sortedFolders.Sort(StringComparer.Ordinal);
608+
609+
string compositeInput = string.Join("\n",
610+
GVFSJsonOptions.Serialize(sortedFiles),
611+
GVFSJsonOptions.Serialize(sortedFolders),
612+
hydrate.ToString());
613+
614+
byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(compositeInput));
615+
return Convert.ToHexString(hashBytes);
616+
}
617+
596618
public class FetchException : Exception
597619
{
598620
public FetchException(string format, params object[] args)
599621
: base(string.Format(format, args))
600622
{
601623
}
602624
}
603-
604-
private static class PrefetchArgs
605-
{
606-
public const string CommitId = "CommitId";
607-
public const string Files = "Files";
608-
public const string Folders = "Folders";
609-
public const string Hydrate = "Hydrate";
610-
}
611625
}
612626
}

GVFS/GVFS.UnitTests/Prefetch/BlobPrefetcherTests.cs

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
using GVFS.Common.Prefetch;
1+
using GVFS.Common;
2+
using GVFS.Common.Prefetch;
23
using GVFS.Tests.Should;
4+
using GVFS.UnitTests.Mock;
5+
using GVFS.UnitTests.Mock.Common;
36
using GVFS.UnitTests.Mock.FileSystem;
47
using NUnit.Framework;
8+
using System.Collections.Generic;
59
using System.IO;
610

711
namespace GVFS.UnitTests.Prefetch
812
{
913
[TestFixture]
1014
public class BlobPrefetcherTests
1115
{
16+
private const string MockCacheFileName = "mock:\\prefetch-cache.dat";
17+
1218
[TestCase]
1319
public void AppendToNewlineSeparatedFileTests()
1420
{
@@ -29,5 +35,207 @@ public void AppendToNewlineSeparatedFileTests()
2935
BlobPrefetcher.AppendToNewlineSeparatedFile(fileSystem, testFileName, "expected line 2");
3036
fileSystem.ReadAllText(testFileName).ShouldEqual("existing content\nexpected line 2\n");
3137
}
38+
39+
[TestCase]
40+
public void ComputeCacheKeyIsDeterministic()
41+
{
42+
List<string> files = new List<string> { "src/a.cs", "src/b.cs" };
43+
List<string> folders = new List<string> { "src/dir1", "src/dir2" };
44+
45+
string key1 = BlobPrefetcher.ComputeCacheKey(files, folders, hydrate: false);
46+
string key2 = BlobPrefetcher.ComputeCacheKey(files, folders, hydrate: false);
47+
48+
key1.ShouldEqual(key2);
49+
}
50+
51+
[TestCase]
52+
public void ComputeCacheKeyDiffersForDifferentFiles()
53+
{
54+
List<string> files1 = new List<string> { "src/a.cs" };
55+
List<string> files2 = new List<string> { "src/b.cs" };
56+
List<string> folders = new List<string> { "src/dir1" };
57+
58+
string key1 = BlobPrefetcher.ComputeCacheKey(files1, folders, hydrate: false);
59+
string key2 = BlobPrefetcher.ComputeCacheKey(files2, folders, hydrate: false);
60+
61+
key1.ShouldNotEqual(key2);
62+
}
63+
64+
[TestCase]
65+
public void ComputeCacheKeyDiffersForDifferentFolders()
66+
{
67+
List<string> files = new List<string> { "src/a.cs" };
68+
List<string> folders1 = new List<string> { "src/dir1" };
69+
List<string> folders2 = new List<string> { "src/dir2" };
70+
71+
string key1 = BlobPrefetcher.ComputeCacheKey(files, folders1, hydrate: false);
72+
string key2 = BlobPrefetcher.ComputeCacheKey(files, folders2, hydrate: false);
73+
74+
key1.ShouldNotEqual(key2);
75+
}
76+
77+
[TestCase]
78+
public void ComputeCacheKeyDiffersForHydrateFlag()
79+
{
80+
List<string> files = new List<string> { "src/a.cs" };
81+
List<string> folders = new List<string> { "src/dir1" };
82+
83+
string key1 = BlobPrefetcher.ComputeCacheKey(files, folders, hydrate: false);
84+
string key2 = BlobPrefetcher.ComputeCacheKey(files, folders, hydrate: true);
85+
86+
key1.ShouldNotEqual(key2);
87+
}
88+
89+
[TestCase]
90+
public void ComputeCacheKeyIsOrderIndependent()
91+
{
92+
List<string> filesA = new List<string> { "src/b.cs", "src/a.cs" };
93+
List<string> filesB = new List<string> { "src/a.cs", "src/b.cs" };
94+
List<string> folders = new List<string> { "src/dir1" };
95+
96+
string key1 = BlobPrefetcher.ComputeCacheKey(filesA, folders, hydrate: false);
97+
string key2 = BlobPrefetcher.ComputeCacheKey(filesB, folders, hydrate: false);
98+
99+
key1.ShouldEqual(key2);
100+
}
101+
102+
[TestCase]
103+
public void ComputeCacheKeyFolderOrderIndependent()
104+
{
105+
List<string> files = new List<string> { "src/a.cs" };
106+
List<string> foldersA = new List<string> { "src/dir2", "src/dir1" };
107+
List<string> foldersB = new List<string> { "src/dir1", "src/dir2" };
108+
109+
string key1 = BlobPrefetcher.ComputeCacheKey(files, foldersA, hydrate: false);
110+
string key2 = BlobPrefetcher.ComputeCacheKey(files, foldersB, hydrate: false);
111+
112+
key1.ShouldEqual(key2);
113+
}
114+
115+
[TestCase]
116+
public void IsNoopPrefetchReturnsFalseWhenCacheIsNull()
117+
{
118+
MockTracer tracer = new MockTracer();
119+
List<string> files = new List<string> { "src/a.cs" };
120+
List<string> folders = new List<string> { "src/dir1" };
121+
122+
BlobPrefetcher.IsNoopPrefetch(tracer, null, "abc123", files, folders, false).ShouldEqual(false);
123+
}
124+
125+
[TestCase]
126+
public void IsNoopPrefetchReturnsFalseWhenCacheIsEmpty()
127+
{
128+
MockTracer tracer = new MockTracer();
129+
List<string> files = new List<string> { "src/a.cs" };
130+
List<string> folders = new List<string> { "src/dir1" };
131+
FileBasedDictionary<string, string> cache = CreateEmptyCache();
132+
133+
BlobPrefetcher.IsNoopPrefetch(tracer, cache, "abc123", files, folders, false).ShouldEqual(false);
134+
}
135+
136+
[TestCase]
137+
public void IsNoopPrefetchReturnsTrueOnCacheHit()
138+
{
139+
MockTracer tracer = new MockTracer();
140+
List<string> files = new List<string> { "src/a.cs" };
141+
List<string> folders = new List<string> { "src/dir1" };
142+
string commitId = "abc123";
143+
144+
FileBasedDictionary<string, string> cache = CreateEmptyCache();
145+
string cacheKey = BlobPrefetcher.ComputeCacheKey(files, folders, hydrate: false);
146+
cache.SetValueAndFlush(cacheKey, commitId);
147+
148+
BlobPrefetcher.IsNoopPrefetch(tracer, cache, commitId, files, folders, false).ShouldEqual(true);
149+
}
150+
151+
[TestCase]
152+
public void IsNoopPrefetchReturnsFalseWhenCommitIdChanged()
153+
{
154+
MockTracer tracer = new MockTracer();
155+
List<string> files = new List<string> { "src/a.cs" };
156+
List<string> folders = new List<string> { "src/dir1" };
157+
158+
FileBasedDictionary<string, string> cache = CreateEmptyCache();
159+
string cacheKey = BlobPrefetcher.ComputeCacheKey(files, folders, hydrate: false);
160+
cache.SetValueAndFlush(cacheKey, "oldcommit");
161+
162+
BlobPrefetcher.IsNoopPrefetch(tracer, cache, "newcommit", files, folders, false).ShouldEqual(false);
163+
}
164+
165+
[TestCase]
166+
public void IsNoopPrefetchSupportsMultipleEntries()
167+
{
168+
MockTracer tracer = new MockTracer();
169+
List<string> filesA = new List<string> { "src/a.cs" };
170+
List<string> filesB = new List<string> { "src/b.cs" };
171+
List<string> folders = new List<string> { "src/dir1" };
172+
string commitId = "abc123";
173+
174+
FileBasedDictionary<string, string> cache = CreateEmptyCache();
175+
176+
string keyA = BlobPrefetcher.ComputeCacheKey(filesA, folders, hydrate: false);
177+
cache.SetValueAndFlush(keyA, commitId);
178+
179+
string keyB = BlobPrefetcher.ComputeCacheKey(filesB, folders, hydrate: false);
180+
cache.SetValueAndFlush(keyB, commitId);
181+
182+
// Both should hit
183+
BlobPrefetcher.IsNoopPrefetch(tracer, cache, commitId, filesA, folders, false).ShouldEqual(true);
184+
BlobPrefetcher.IsNoopPrefetch(tracer, cache, commitId, filesB, folders, false).ShouldEqual(true);
185+
186+
// A third pattern should miss
187+
List<string> filesC = new List<string> { "src/c.cs" };
188+
BlobPrefetcher.IsNoopPrefetch(tracer, cache, commitId, filesC, folders, false).ShouldEqual(false);
189+
}
190+
191+
private static FileBasedDictionary<string, string> CreateEmptyCache()
192+
{
193+
CacheFileSystem fs = new CacheFileSystem();
194+
fs.ExpectedFiles.Add(MockCacheFileName, new ReusableMemoryStream(string.Empty));
195+
fs.ExpectedOpenFileStreams.Add(MockCacheFileName + ".tmp", new ReusableMemoryStream(string.Empty));
196+
fs.ExpectedOpenFileStreams.Add(MockCacheFileName, fs.ExpectedFiles[MockCacheFileName]);
197+
198+
FileBasedDictionary<string, string>.TryCreate(
199+
null,
200+
MockCacheFileName,
201+
fs,
202+
out FileBasedDictionary<string, string> cache,
203+
out string error).ShouldEqual(true, error);
204+
205+
fs.ExpectedOpenFileStreams.Remove(MockCacheFileName);
206+
return cache;
207+
}
208+
209+
private class CacheFileSystem : ConfigurableFileSystem
210+
{
211+
public CacheFileSystem()
212+
{
213+
this.ExpectedOpenFileStreams = new Dictionary<string, ReusableMemoryStream>();
214+
}
215+
216+
public Dictionary<string, ReusableMemoryStream> ExpectedOpenFileStreams { get; }
217+
218+
public override Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare shareMode, FileOptions options, bool flushesToDisk)
219+
{
220+
this.ExpectedOpenFileStreams.TryGetValue(path, out ReusableMemoryStream stream);
221+
222+
if (fileMode == FileMode.Create)
223+
{
224+
this.ExpectedFiles[path] = new ReusableMemoryStream(string.Empty);
225+
}
226+
227+
this.ExpectedFiles.TryGetValue(path, out stream).ShouldEqual(true, "Unexpected access of file: " + path);
228+
return stream;
229+
}
230+
231+
public override void MoveAndOverwriteFile(string sourceFileName, string destinationFilename)
232+
{
233+
this.ExpectedFiles.TryGetValue(sourceFileName, out ReusableMemoryStream source).ShouldEqual(true, "Source file does not exist: " + sourceFileName);
234+
this.ExpectedFiles.ContainsKey(destinationFilename).ShouldEqual(true, "MoveAndOverwriteFile expects the destination file to exist: " + destinationFilename);
235+
236+
this.ExpectedFiles.Remove(sourceFileName);
237+
this.ExpectedFiles[destinationFilename] = source;
238+
}
239+
}
32240
}
33241
}

0 commit comments

Comments
 (0)