Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
0ff8a5c
feat(caching): implement negative stat cache to optimize polling of m…
alleaditya May 25, 2026
6ed7904
docs(fs): add detailed comment explaining LookUpChild short-circuit l…
alleaditya May 25, 2026
633c027
style: fix formatting in bucket_manager.go
alleaditya May 25, 2026
5afbc7f
fix(caching): address review comments and resolve data races/deadlock…
alleaditya May 25, 2026
3bf09de
style: remove extra blank line in fast_stat_bucket.go
alleaditya May 25, 2026
063c48a
refactor(caching): remove unrelated fixes and speculative listing cac…
alleaditya May 25, 2026
6cd627c
fix(caching): add negative caching for empty directories in fast_stat…
alleaditya Jun 4, 2026
85ef618
Revert changes to internal/fs/inode/dir.go as requested by reviewer
alleaditya Jun 4, 2026
7844a2d
fix(caching): remove speculative negative caching from insertListing
alleaditya Jun 8, 2026
a41f2f7
fix(caching): safely re-add negative caching to insertListing
alleaditya Jun 8, 2026
e4c4072
style: apply go fmt to tests
alleaditya Jun 8, 2026
b04bef2
chore: revert unintended formatting changes in hns_bucket_test.go
alleaditya Jun 8, 2026
0dfc7f6
style(caching): combine nested if conditions in insertListing
alleaditya Jun 8, 2026
855f4f0
fix(caching): move negative caching TTL check to caller
alleaditya Jun 8, 2026
0370a6a
refactor(caching): clean up boilerplate if-else blocks by using a hel…
alleaditya Jun 8, 2026
e70f114
Fix cache bug and combine condition
alleaditya Jun 8, 2026
ef534ee
Add b.implicitDir check to negative cache insertion condition
alleaditya Jun 10, 2026
abf86a8
Address PR comment: Use newline to separate AAA in gcs_metrics_test
alleaditya Jun 11, 2026
b928d18
Merge remote-tracking branch 'upstream/master' into add-negative-stat…
alleaditya Jun 11, 2026
f7e0e0c
test(metrics): use t.TempDir() instead of hardcoded tmp directory in …
alleaditya Jun 11, 2026
1311bb8
test(metrics): update short-circuit test to simulate list call with -…
alleaditya Jun 11, 2026
21c24f0
test(metrics): clean up comments in TestGCSMetrics_RequestCount_Negat…
alleaditya Jun 12, 2026
955c914
Update internal/fs/gcs_metrics_test.go
alleaditya Jun 15, 2026
e5a2c07
refactor(metrics): remove unused type definition in gcs_metrics_test
alleaditya Jun 15, 2026
5abeb0b
Short-circuit ListObjects if negative cache is hit
alleaditya Jun 16, 2026
0e32dbe
test(metrics): advance cacheClock for cross-mount tests
alleaditya Jun 16, 2026
5c2ff9f
fix(cache): restrict implicit dir negative caching and inference to d…
alleaditya Jun 16, 2026
fd86253
style(cache): apply De Morgan's law to satisfy staticcheck QF1001
alleaditya Jun 16, 2026
b7e0bdc
Fix staticcheck QF1001 and allow short-circuit in ListObjects for exp…
alleaditya Jun 16, 2026
fc4f668
Revert "allow short-circuit in ListObjects for explicit-dirs and HNS"
alleaditya Jun 16, 2026
006f3d9
fix(cache): preserve folder in LRU and fix ListObjects short-circuit …
alleaditya Jun 17, 2026
74843d4
Revert "fix(cache): preserve folder in LRU and fix ListObjects short-…
alleaditya Jun 17, 2026
ceb9bb1
fix(cache): fix ListObjects short-circuit for HNS folders
alleaditya Jun 17, 2026
51e42bc
Merge remote-tracking branch 'upstream/master' into add-negative-stat…
alleaditya Jun 17, 2026
c2fae1f
Fix integration test cache issues
alleaditya Jun 18, 2026
76b9fad
Address PR comments: Move negative cache to implicit directories sect…
alleaditya Jun 18, 2026
071d269
Refactor ListObjects negative cache short-circuiting logic
alleaditya Jun 18, 2026
891c487
Fix build error: pass time.Time to statCache LookUp
alleaditya Jun 18, 2026
bf412c2
Simplify ListObjects short-circuiting by removing unreachable HNS buc…
alleaditya Jun 18, 2026
c8ab55a
Revert addition of --metadata-cache-negative-ttl-secs=2 in dentry_cac…
alleaditya Jun 18, 2026
beab398
test: use unique t.TempDir() in file_cache_reader_test to fix cross-s…
alleaditya Jun 18, 2026
27d4998
test: restore --metadata-cache-negative-ttl-secs=2 to dentry_cache tests
alleaditya Jun 18, 2026
e0da02e
test: restore --metadata-cache-negative-ttl-secs=2 to test_config.yaml
alleaditya Jun 19, 2026
b9f8514
Fix integration test failures and restore flags
alleaditya Jun 19, 2026
9b1dd6d
test: Add negative TTL flag to CI integration test config
alleaditya Jun 19, 2026
f430837
Fix dentry_cache test failures due to implicit directory caching
alleaditya Jun 19, 2026
109b209
Revert addition of --metadata-cache-negative-ttl-secs in test_config.…
alleaditya Jun 19, 2026
10e5e13
Revert test changes as requested
alleaditya Jun 19, 2026
d8b9371
Remove negative caching of empty directories in insertListing
alleaditya Jun 19, 2026
6b0387e
Run go fmt
alleaditya Jun 19, 2026
ac90e84
Fix file_cache_reader_test crosstalk
alleaditya Jun 19, 2026
abf58ba
Merge branch 'master' into add-negative-stat-cache
alleaditya Jun 19, 2026
0bc9494
Restore negative stat cache for empty directories
alleaditya Jun 19, 2026
09de3a3
Fix dentry_cache integration tests by clearing test cache
alleaditya Jun 19, 2026
7c18968
Fix dentry_cache integration tests in test_config.yaml
alleaditya Jun 20, 2026
506cb06
Revert unnecessary destroy call
alleaditya Jun 20, 2026
b62cc6b
Fix redundant ListObjects calls by negatively caching missing directo…
alleaditya Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ The behavior of stat cache is controlled by the following flags/config parameter
If this config variable is missing, then the value of ```--stat-cache-ttl``` is used.
* ```--stat-cache-ttl``` commandline flag, which can be set to a value like ```10s``` or ```1.5h```. The default is one minute. This has been deprecated (starting v2.0) and is currently only available for backward compatibility. If ```metadata-cache: ttl-secs``` is set, ```--stat-cache-ttl``` is ignored.

Positive and negative stat results will be cached for the specified amount of time.
Positive stat results (existing files) will be cached for this specified amount of time.

3. **Negative Stat-cache (Non-existent Entry Caching)**: Controls caching of non-existent paths (`ErrNotExist` / 404 results) to optimize workloads that aggressively poll missing files (e.g., JupyterLab).
* `metadata-cache: negative-ttl-secs` in the config-file (integer). Sets the TTL in seconds for negative entries. Default is 5 seconds. Use -1 for infinite TTL. Setting this to 0 disables negative caching.
Comment thread
alleaditya marked this conversation as resolved.

Warnings:
- Using stat caching breaks the consistency guarantees discussed in this document. It is safe only in the following situations:
Expand Down
84 changes: 84 additions & 0 deletions internal/fs/gcs_metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@ import (
"os"
"testing"

"time"

"github.com/googlecloudplatform/gcsfuse/v3/cfg"
"github.com/googlecloudplatform/gcsfuse/v3/internal/cache/lru"
"github.com/googlecloudplatform/gcsfuse/v3/internal/cache/metadata"
"github.com/googlecloudplatform/gcsfuse/v3/internal/fs"
"github.com/googlecloudplatform/gcsfuse/v3/internal/fs/wrappers"
"github.com/googlecloudplatform/gcsfuse/v3/internal/gcsx"
"github.com/googlecloudplatform/gcsfuse/v3/internal/monitor"
"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/caching"
"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/fake"
"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/gcs"
"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/storageutil"
Expand Down Expand Up @@ -463,3 +468,82 @@ func TestGCSMetrics_RetryCount(t *testing.T) {
attribute.NewSet(attribute.String("retry_error_category", "STALLED_READ_REQUEST")),
1)
}

type fakeBucketManagerForShortCircuit struct {
bucket gcs.Bucket
}

func (bm *fakeBucketManagerForShortCircuit) SetUpBucket(ctx context.Context, name string, _ bool, _ metrics.MetricHandle) (gcsx.SyncerBucket, error) {
return gcsx.NewSyncerBucket(0, 120, 10, ".gcsfuse_tmp/", gcsx.NewContentTypeBucket(bm.bucket)), nil
}
func (bm *fakeBucketManagerForShortCircuit) ShutDown() {}
Comment thread
alleaditya marked this conversation as resolved.

// TestGCSMetrics_RequestCount_NegativeCachingShortCircuit validates that when negative entry caching is enabled,
// repeated lookups for non-existent files short-circuit in memory and do not emit redundant backend GCS requests.
func TestGCSMetrics_RequestCount_NegativeCachingShortCircuit(t *testing.T) {
Comment thread
alleaditya marked this conversation as resolved.
ctx := context.Background()
origProvider := otel.GetMeterProvider()
t.Cleanup(func() { otel.SetMeterProvider(origProvider) })
reader := metric.NewManualReader()
provider := metric.NewMeterProvider(metric.WithReader(reader))
otel.SetMeterProvider(provider)

mh, err := metrics.NewOTelMetrics(ctx, 1, 100)
require.NoError(t, err, "metrics.NewOTelMetrics")

bucketName := "test-bucket"
rawBucket := fake.NewFakeBucket(timeutil.RealClock(), bucketName, gcs.BucketType{Hierarchical: false})

// Enforce production wrapping order: Caching layer MUST wrap Monitoring layer.
monitoringInnerBucket := monitor.NewMonitoringBucket(rawBucket, mh)
lruCache := lru.NewCache(1024 * 1024)
statCacheView := metadata.NewStatCacheBucketView(lruCache, "")
cachedOuterBucket := caching.NewFastStatBucket(
time.Minute,
statCacheView,
timeutil.RealClock(),
monitoringInnerBucket,
time.Minute,
true, // isTypeCacheDeprecated
true, // implicitDir
)

serverCfg := &fs.ServerConfig{
NewConfig: &cfg.Config{
EnableNewReader: true,
},
MetricHandle: mh,
TraceHandle: tracing.NewNoopTracer(),
CacheClock: &timeutil.SimulatedClock{},
BucketName: bucketName,
BucketManager: &fakeBucketManagerForShortCircuit{
bucket: cachedOuterBucket,
},
Comment thread
alleaditya marked this conversation as resolved.
}

server, err := fs.NewFileSystem(ctx, serverCfg)
require.NoError(t, err, "NewFileSystem")

lookupOp := &fuseops.LookUpInodeOp{
Parent: fuseops.RootInodeID,
Name: "missing_file",
}

// 1. First Lookup (Cache Miss) -> Emits backend probes through monitoring layer
_ = server.LookUpInode(ctx, lookupOp)
waitForMetricsProcessing()

// Probing a missing file checks dir then file -> 2 actual backend network calls.
metrics.VerifyCounterMetric(t, ctx, reader, "gcs/request_count",
Comment thread
alleaditya marked this conversation as resolved.
attribute.NewSet(attribute.String("gcs_method", "StatObject")),
2)

// 2. Second Lookup (Negative Cache Hit) -> Intercepted by outer cache layer
_ = server.LookUpInode(ctx, lookupOp)
waitForMetricsProcessing()

// Verify backend request count remains exactly 2 (zero new network requests emitted)
metrics.VerifyCounterMetric(t, ctx, reader, "gcs/request_count",
attribute.NewSet(attribute.String("gcs_method", "StatObject")),
2)
}
3 changes: 2 additions & 1 deletion internal/fs/hns_bucket_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ func (t *HNSCachedBucketMountTest) SetupSuite() {
uncachedHNSBucket,
negativeCacheTTL,
IsTypeCacheDeprecated,
isImplicitDir)
isImplicitDir,
)

// Enable directory type caching.
t.serverCfg.DirTypeCacheTTL = ttl
Expand Down
22 changes: 17 additions & 5 deletions internal/fs/inode/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ func (d *dirInode) LookUpChild(ctx context.Context, name string) (*Core, error)
// 1. Try Directory FIRST (since it's the preferred return type)
var dirResult *Core
var err error
var dirCacheMiss bool
Comment thread
alleaditya marked this conversation as resolved.
Outdated
if d.Bucket().BucketType().Hierarchical {
dirResult, err = findExplicitFolder(ctx, d.Bucket(), NewDirName(d.Name(), name), true)
} else {
Expand All @@ -696,19 +697,30 @@ func (d *dirInode) LookUpChild(ctx context.Context, name string) (*Core, error)
if dirResult != nil {
return dirResult, nil
}
// If we hit a real error (not a cache miss), exit early.
if err != nil && !errors.As(err, &cacheMissErr) {
if errors.As(err, &cacheMissErr) {
dirCacheMiss = true
} else if err != nil {
return nil, err
}

// 2. Try File ONLY if directory wasn't found
var fileCacheMiss bool
fileResult, err := findExplicitInode(ctx, d.Bucket(), NewFileName(d.Name(), name), true)
if err != nil && !errors.As(err, &cacheMissErr) {
if fileResult != nil {
return fileResult, nil
}
if errors.As(err, &cacheMissErr) {
fileCacheMiss = true
} else if err != nil {
return nil, err
}

if fileResult != nil {
return fileResult, nil
// 3. Short-circuit ONLY on confirmed negative hits (tombstones) for BOTH
// directory and file paths. If either lookup resulted in a cache miss,
// we cannot short-circuit and must fall back to GCS (fetchCoreEntity)
// because the un-cached type might still exist.
if !dirCacheMiss && !fileCacheMiss {
return nil, nil // Definitive ENOENT
}
}

Expand Down
6 changes: 3 additions & 3 deletions internal/gcsx/bucket_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ type BucketConfig struct {
// Config for TTL of entries for existing file in stat cache
StatCacheTTL time.Duration
// Config for TTL of entries for non-existing file in stat cache
NegativeStatCacheTTL time.Duration
EnableMonitoring bool
LogSeverity cfg.LogSeverity
NegativeStatCacheTTL time.Duration
EnableMonitoring bool
LogSeverity cfg.LogSeverity

// Files backed by on object of length at least AppendThreshold that have
// only been appended to (i.e. none of the object's contents have been
Expand Down
29 changes: 29 additions & 0 deletions internal/storage/caching/fast_stat_bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ func (b *fastStatBucket) insertListing(ctx context.Context, listing *gcs.Listing
return
}

// If the directory listing returned from GCS is empty (both MinObjects and CollapsedRuns
// are empty), it means the directory is either an implicit directory that no longer
// has any children (because they were deleted) or a non-existent directory.
// In either case, if negative caching is enabled (negativeCacheTTL > 0), we cache
// this directory name with a trailing slash as a negative entry to short-circuit
// subsequent lookups.
// Note: Explicit empty directories have a placeholder object (e.g., "dir/"),
// which will be returned in MinObjects, so they will not trigger this negative caching.
if len(listing.MinObjects) == 0 && len(listing.CollapsedRuns) == 0 && b.negativeCacheTTL > 0 && strings.HasSuffix(dirName, "/") {
b.cache.AddNegativeEntry(dirName, b.clock.Now().Add(b.negativeCacheTTL))
return
}
Comment thread
alleaditya marked this conversation as resolved.
Outdated

expiration := b.clock.Now().Add(b.primaryCacheTTL)

// 1. Parent Directory Inference (Implicit Check)
Expand Down Expand Up @@ -244,6 +257,11 @@ func (b *fastStatBucket) addNegativeEntry(name string) {
b.mu.Lock()
defer b.mu.Unlock()

if b.negativeCacheTTL <= 0 {
b.cache.Erase(name)
Comment thread
alleaditya marked this conversation as resolved.
Outdated
return
}

expiration := b.clock.Now().Add(b.negativeCacheTTL)
b.cache.AddNegativeEntry(name, expiration)
}
Expand All @@ -253,6 +271,11 @@ func (b *fastStatBucket) addNegativeEntryForFolder(name string) {
b.mu.Lock()
defer b.mu.Unlock()

if b.negativeCacheTTL <= 0 {
b.cache.Erase(name)
return
}

expiration := b.clock.Now().Add(b.negativeCacheTTL)
b.cache.AddNegativeEntryForFolder(name, expiration)
}
Expand All @@ -271,6 +294,9 @@ func (b *fastStatBucket) lookUp(name string) (hit bool, m *gcs.MinObject) {
defer b.mu.Unlock()

hit, m = b.cache.LookUp(name, b.clock.Now())
if hit && m == nil && b.negativeCacheTTL <= 0 {
Comment thread
alleaditya marked this conversation as resolved.
Outdated
return false, nil
}
return
}

Expand All @@ -279,6 +305,9 @@ func (b *fastStatBucket) lookUpFolder(name string) (bool, *gcs.Folder) {
defer b.mu.Unlock()

hit, f := b.cache.LookUpFolder(name, b.clock.Now())
if hit && f == nil && b.negativeCacheTTL <= 0 {
return false, nil
}
return hit, f
}

Expand Down
35 changes: 32 additions & 3 deletions internal/storage/caching/fast_stat_bucket_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,15 @@ func (t *StatObjectTest) CacheHit_Negative() {
ExpectThat(err, HasSameTypeAs(&gcs.NotFoundError{}))
}

func (t *StatObjectTest) CacheHit_Negative_Disabled_FetchOnly() {
Comment thread
alleaditya marked this conversation as resolved.
Outdated
t.bucket = caching.NewFastStatBucket(primaryCacheTTL, t.cache, &t.clock, t.wrapped, 0, true, true)
const name = "taco"
ExpectCall(t.cache, "LookUp")(Any(), Any()).WillOnce(Return(true, nil))
req := &gcs.StatObjectRequest{Name: name, FetchOnlyFromCache: true}
_, _, err := t.bucket.StatObject(context.Background(), req) //nolint:govet
ExpectThat(err, HasSameTypeAs(&caching.CacheMissError{}))
}

func (t *StatObjectTest) IgnoresCacheEntryWhenForceFetchFromGcsIsTrue() {
const name = "taco"

Expand Down Expand Up @@ -790,10 +799,21 @@ func (t *StatObjectTest) WrappedSaysNotFound() {
}

_, _, err := t.bucket.StatObject(context.TODO(), req)
ExpectThat(err, HasSameTypeAs(&gcs.NotFoundError{}))
ExpectThat(err, Error(HasSubstr("burrito")))
}

func (t *StatObjectTest) WrappedSaysNotFound_NegativeCachingDisabled() {
t.bucket = caching.NewFastStatBucket(primaryCacheTTL, t.cache, &t.clock, t.wrapped, 0, true, true)
const name = "taco"
ExpectCall(t.cache, "LookUp")(Any(), Any()).WillOnce(Return(false, nil))
ExpectCall(t.wrapped, "StatObject")(Any(), Any()).WillOnce(Return(nil, nil, &gcs.NotFoundError{Err: errors.New("burrito")}))
// Expect Erase call to invalidate any stale entry
ExpectCall(t.cache, "Erase")(name)
req := &gcs.StatObjectRequest{Name: name}
_, _, err := t.bucket.StatObject(context.Background(), req) //nolint:govet
ExpectThat(err, HasSameTypeAs(&gcs.NotFoundError{}))
}

func (t *StatObjectTest) WrappedSucceeds() {
const name = "taco"

Expand Down Expand Up @@ -997,7 +1017,7 @@ func (t *ListObjectsTest_InsertListing) SetUp(ti *TestInfo) {
t.cache,
&t.clock,
t.wrapped,
negativeCacheTTL,
0,
true,
true)
}
Expand Down Expand Up @@ -1031,6 +1051,15 @@ func (t *ListObjectsTest_InsertListing) EmptyListing() {
t.callAndVerify(context.TODO(), false, listing, "dir/", expectedInserts, expectedImplicitDirs)
}

func (t *ListObjectsTest_InsertListing) EmptyListing_NegativeCaching() {
t.bucket = caching.NewFastStatBucket(primaryCacheTTL, t.cache, &t.clock, t.wrapped, negativeCacheTTL, true, true)
listing := &gcs.Listing{}
ExpectCall(t.wrapped, "BucketType")().WillOnce(Return(gcs.BucketType{}))
ExpectCall(t.wrapped, "ListObjects")(Any(), Any()).WillOnce(Return(listing, nil))
ExpectCall(t.cache, "AddNegativeEntry")("dir/", Any())
_, _ = t.bucket.ListObjects(context.Background(), &gcs.ListObjectsRequest{Prefix: "dir/"}) //nolint:govet
}

func (t *ListObjectsTest_InsertListing) ObjectsOnly() {
listing := &gcs.Listing{
MinObjects: []*gcs.MinObject{
Expand Down Expand Up @@ -1134,7 +1163,7 @@ func (t *ListObjectsTest_InsertListing) ImplicitDirFalse_CollapsedRunsNotCached(
t.cache,
&t.clock,
t.wrapped,
negativeCacheTTL,
0,
true,
false)
listing := &gcs.Listing{
Expand Down
7 changes: 7 additions & 0 deletions internal/storage/fake/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"time"
"unicode/utf8"

"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/caching"
"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/gcs"
"github.com/googlecloudplatform/gcsfuse/v3/internal/storage/storageutil"
"github.com/jacobsa/syncutil"
Expand Down Expand Up @@ -900,6 +901,9 @@ func (b *bucket) ComposeObjects(
// LOCKS_EXCLUDED(b.mu)
func (b *bucket) StatObject(ctx context.Context,
req *gcs.StatObjectRequest) (m *gcs.MinObject, e *gcs.ExtendedObjectAttributes, err error) {
if req.FetchOnlyFromCache {
return nil, nil, &caching.CacheMissError{Err: errors.New("fake bucket has no cache")}
}
// If ExtendedObjectAttributes are requested without fetching from gcs enabled, panic.
if !req.ForceFetchFromGcs && req.ReturnExtendedObjectAttributes {
panic("invalid StatObjectRequest: ForceFetchFromGcs: false and ReturnExtendedObjectAttributes: true")
Expand Down Expand Up @@ -1143,6 +1147,9 @@ func (b *bucket) DeleteFolder(ctx context.Context, folderName string) (err error
}

func (b *bucket) GetFolder(ctx context.Context, req *gcs.GetFolderRequest) (*gcs.Folder, error) {
if req.FetchOnlyFromCache {
return nil, &caching.CacheMissError{Err: errors.New("fake bucket has no cache")}
}
b.mu.Lock()
defer b.mu.Unlock()

Expand Down
Loading