Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/mount/fuse/main.cc
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ static int mainloop(struct fuse_args *args, struct fuse_cmdline_opts *fuse_opts,
params.keep_cache = gMountOptions.keepcache;
params.direntry_cache_timeout = gMountOptions.direntrycacheto;
params.direntry_cache_size = gMountOptions.direntrycachesize;
params.negative_cache_timeout = gMountOptions.negativecachetimeout;
params.negative_cache_size = gMountOptions.negativecachesize;
params.entry_cache_timeout = gMountOptions.entrycacheto;
params.attr_cache_timeout = gMountOptions.attrcacheto;
params.mkdir_copy_sgid = gMountOptions.mkdircopysgid;
Expand Down
14 changes: 14 additions & 0 deletions src/mount/fuse/mount_config.cc
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ struct fuse_opt gSfsOptsStage2[] = {
SFS_OPT("sfsentrycacheto=%lf", entrycacheto, 0),
SFS_OPT("sfsdirectio=%d", directio, 0),
SFS_OPT("sfsdirentrycacheto=%lf", direntrycacheto, 0),
SFS_OPT("sfsnegativecachetimeout=%u", negativecachetimeout, 0),
SFS_OPT("sfsnegativecachesize=%u", negativecachesize, 0),
SFS_OPT("sfsaclcacheto=%lf", aclcacheto, 0),
SFS_OPT("sfsreportreservedperiod=%u", reportreservedperiod, 0),
SFS_OPT("sfsiolimits=%s", iolimits, 0),
Expand Down Expand Up @@ -168,6 +170,8 @@ void initialize_opts_name_values() {
gOptsNameValues["sfsentrycacheto"] = std::to_string(gMountOptions.entrycacheto);
gOptsNameValues["sfsdirectio"] = std::to_string(gMountOptions.directio);
gOptsNameValues["sfsdirentrycacheto"] = std::to_string(gMountOptions.direntrycacheto);
gOptsNameValues["sfsnegativecachetimeout"] = std::to_string(gMountOptions.negativecachetimeout);
gOptsNameValues["sfsnegativecachesize"] = std::to_string(gMountOptions.negativecachesize);
gOptsNameValues["sfsaclcacheto"] = std::to_string(gMountOptions.aclcacheto);
gOptsNameValues["sfsreportreservedperiod"] = std::to_string(gMountOptions.reportreservedperiod);
gOptsNameValues["sfsiolimits"] =
Expand Down Expand Up @@ -317,6 +321,14 @@ void usage(const char *progname) {
"(default: %.2f)\n"
" -o sfsdirentrycachesize=N define directory entry cache size in number "
"of entries (default: %u)\n"
" -o sfsnegativecachetimeout=MSEC set negative cache timeout to determine "
"how long client remembers a failed lookup. When equal to 0 "
"disabled for both internal and Linux kernel-level negative caching. "
"If changed in .saunafs_tweaks clears the whole cache (default: %u)\n"
" -o sfsnegativecachesize=N define internal negative cache max size in number of entries. "
"Prevents network requests if the kernel evicts entries early. "
"When equal to 0 disabled for both internal and Linux kernel-level negative caching. "
"If changed in .saunafs_tweaks clears the whole cache (default: %u)\n"
" -o sfsaclcacheto=SEC set ACL cache timeout in seconds (default: %.2f)\n"
" -o sfsreportreservedperiod=SEC set reporting reserved inodes interval in "
"seconds (default: %u)\n"
Expand Down Expand Up @@ -392,6 +404,8 @@ void usage(const char *progname) {
SaunaClient::FsInitParams::kDefaultEntryCacheTimeout,
SaunaClient::FsInitParams::kDefaultDirentryCacheTimeout,
SaunaClient::FsInitParams::kDefaultDirentryCacheSize,
SaunaClient::FsInitParams::kDefaultNegativeCacheTo,
SaunaClient::FsInitParams::kDefaultNegativeCacheSize,
SaunaClient::FsInitParams::kDefaultAclCacheTimeout,
SaunaClient::FsInitParams::kDefaultReportReservedPeriod,
SaunaClient::FsInitParams::kDefaultRoundTime,
Expand Down
4 changes: 4 additions & 0 deletions src/mount/fuse/mount_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ struct sfsopts_ {
double entrycacheto;
double direntrycacheto;
unsigned direntrycachesize;
unsigned negativecachetimeout;
unsigned negativecachesize;
unsigned reportreservedperiod;
char *iolimits;
int chunkserverrtt;
Expand Down Expand Up @@ -164,6 +166,8 @@ struct sfsopts_ {
entrycacheto(SaunaClient::FsInitParams::kDefaultEntryCacheTimeout),
direntrycacheto(SaunaClient::FsInitParams::kDefaultDirentryCacheTimeout),
direntrycachesize(SaunaClient::FsInitParams::kDefaultDirentryCacheSize),
negativecachetimeout(SaunaClient::FsInitParams::kDefaultNegativeCacheTo),
negativecachesize(SaunaClient::FsInitParams::kDefaultNegativeCacheSize),
reportreservedperiod(SaunaClient::FsInitParams::kDefaultReportReservedPeriod),
iolimits(NULL),
chunkserverrtt(SaunaClient::FsInitParams::kDefaultRoundTime),
Expand Down
223 changes: 223 additions & 0 deletions src/mount/negative_cache.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*

Copyright 2026 Leil Storage OÜ

This file is part of SaunaFS.

SaunaFS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3.

SaunaFS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with SaunaFS. If not, see <http://www.gnu.org/licenses/>.
*/

#pragma once

#include <atomic>
#include <cstdint>
#include <list>
#include <mutex>
#include <unordered_map>

#include "common/hashfn.h"
#include "common/shared_mutex.h"
#include "common/type_defs.h"
#include "common/time_utils.h"

inline std::atomic<uint32_t> gNegativeCacheTimeoutMs;
inline std::atomic<uint32_t> gNegativeCacheMaxSize;

class NegativeCache {
public:
NegativeCache() = default;

// To prevent accidental copies of the lock/map/list
NegativeCache(const NegativeCache&) = delete;
NegativeCache& operator=(const NegativeCache&) = delete;
NegativeCache(NegativeCache&&) = delete;
NegativeCache& operator=(NegativeCache&&) = delete;

// Atomic access
static uint32_t getGlobalMaxSize() noexcept {
return gNegativeCacheMaxSize.load();
}

// Atomic access
static void setGlobalMaxSize(uint32_t maxSize) noexcept {
gNegativeCacheMaxSize.store(maxSize);
}

// Locked
// Reserve enough buckets without the map needing to rehash
void setMaxSize(uint32_t maxSize) {
std::unique_lock<shared_mutex> lock(rwLock_);
maxSize_ = maxSize;
negativeCache_.reserve(maxSize_);
}

// Atomic access
static uint32_t getGlobalTimeoutMs() noexcept {
return gNegativeCacheTimeoutMs.load();
}

// Atomic access
static void setGlobalTimeoutMs(uint32_t timeoutMs) noexcept {
gNegativeCacheTimeoutMs.store(timeoutMs);
}

// Locked
void setTimeoutMs(uint32_t timeoutMs) {
std::unique_lock<shared_mutex> lock(rwLock_);
timeoutMs_ = timeoutMs;
}

// Locked
size_t size() const {
shared_lock<shared_mutex> lock(rwLock_);
return negativeCache_.size();
}

// Locked
bool isMaxSizeAndTimeoutMsSet() const {
shared_lock<shared_mutex> lock(rwLock_);
return (maxSize_ > 0) && (timeoutMs_ > 0);
}

// Locked
// Works only if max size and timeout are set
bool lookup(inode_t parent, std::string_view name) const {
if (!isMaxSizeAndTimeoutMsSet()) { return false; }

shared_lock<shared_mutex> lock(rwLock_);
auto it = negativeCache_.find(InodeNameLookupPair{ parent, name });
if (it != negativeCache_.end()) {
NegativeCacheEntry entry = it->second;
if (static_cast<uint64_t>(timer_.elapsed_ms()) < entry.timeoutMs) {
return true;
} // else ignore expired
}
return false;
}

// Locked, LRU eviction policy, entries refreshed
// Works only if max size and timeout are set
void add(inode_t parent, std::string_view name) {
if (!isMaxSizeAndTimeoutMsSet()) { return; }

std::unique_lock<shared_mutex> lock(rwLock_);

// sfsnegativecachetimeout and sfsnegativecachesize
// are not changed in Tweaks frequently, so it is okay
// for consistency
bool clearCache = false;
uint32_t globalTimeoutMs = getGlobalTimeoutMs();
if (timeoutMs_ != globalTimeoutMs) {
timeoutMs_ = globalTimeoutMs;
clearCache = true;
}
uint32_t globalMaxSize = getGlobalMaxSize();
if (maxSize_ != globalMaxSize) {
maxSize_ = globalMaxSize;
negativeCache_.reserve(maxSize_); // doesn't shrink
clearCache = true;
}
if (clearCache) {
negativeCache_.clear();
timeoutList_.clear();
}

InodeNamePairPtr inodeNamePtr;

// Update existing
auto it = negativeCache_.find(InodeNameLookupPair{ parent, name });
if (it != negativeCache_.end()) {
inodeNamePtr = it->first;
timeoutList_.erase(it->second.listIt);
} else {
if (negativeCache_.size() >= maxSize_) {
// Remove oldest
negativeCache_.erase(timeoutList_.front());
timeoutList_.pop_front();
}
inodeNamePtr = std::make_shared<const InodeNamePair>(parent, name);
}
timeoutList_.push_back(inodeNamePtr);

// Update timeout if the same parent/name
negativeCache_.insert_or_assign(inodeNamePtr, NegativeCacheEntry{
.timeoutMs = static_cast<uint64_t>(timer_.elapsed_ms()) + timeoutMs_,
.listIt = std::prev(timeoutList_.end())
});
}

private:
using InodeNamePair = std::pair<inode_t, std::string>;
using InodeNamePairPtr = std::shared_ptr<const InodeNamePair>;
using InodeNameLookupPair = std::pair<inode_t, std::string_view>;

struct NegativeCacheEntry {
uint64_t timeoutMs;
std::list<InodeNamePairPtr>::iterator listIt;
};

struct InodeNameHash {
using is_transparent = void; // zero allocation for string

static uint64_t hashCompute(inode_t parent, std::string_view name) noexcept {
uint64_t seed = 0;
hashCombine(seed, parent, ByteArray(name.data(), name.size()));
return seed;
}

// Storage hash calculation
size_t operator()(const InodeNamePairPtr& obj) const noexcept {
return hashCompute(obj->first, obj->second);
}

// Lookup hash calculation
size_t operator()(const InodeNameLookupPair& obj) const noexcept {
return hashCompute(obj.first, obj.second);
}
};

struct InodeNameEqual {
using is_transparent = void; // zero allocation for string

// Compare two stored pairs
bool operator()(const InodeNamePairPtr& lsp,
const InodeNamePairPtr& rsp) const noexcept {
return lsp->first == rsp->first && lsp->second == rsp->second;
}

// Compare stored pair against lookup pair
bool operator()(const InodeNamePairPtr& lsp,
const InodeNameLookupPair& rsp) const noexcept {
return lsp->first == rsp.first && lsp->second == rsp.second;
}

bool operator()(const InodeNameLookupPair& lsp,
const InodeNamePairPtr& rsp) const noexcept {
return lsp.first == rsp->first && lsp.second == rsp->second;
}
};

mutable shared_mutex rwLock_;
std::unordered_map<
InodeNamePairPtr,
NegativeCacheEntry,
InodeNameHash,
InodeNameEqual
> negativeCache_;
Timer timer_;
size_t maxSize_;
uint32_t timeoutMs_;
std::list<InodeNamePairPtr> timeoutList_;
};

inline NegativeCache gNegativeCache;
102 changes: 102 additions & 0 deletions src/mount/negative_cache_unittest.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*

Copyright 2026 Leil Storage OÜ

This file is part of SaunaFS.

SaunaFS is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, version 3.

SaunaFS is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with SaunaFS. If not, see <http://www.gnu.org/licenses/>.
*/

#include "mount/negative_cache.h"

#include <ranges>

#include <gtest/gtest.h>

class NegativeCacheTest : public testing::Test {
protected:
void SetUp() override {
NegativeCache::setGlobalMaxSize(kMaxSize);
negativeCache.setMaxSize(NegativeCache::getGlobalMaxSize());
NegativeCache::setGlobalTimeoutMs(k1000ms);
negativeCache.setTimeoutMs(NegativeCache::getGlobalTimeoutMs());
}

static constexpr uint32_t kMaxSize = 10;
static constexpr uint32_t k1000ms = 1000;
static constexpr inode_t kParent = 0;
static constexpr std::string_view kName = "some_file";

NegativeCache negativeCache;
};

TEST_F(NegativeCacheTest, InitiallyEmpty) {
EXPECT_EQ(negativeCache.size(), 0U);
}

TEST_F(NegativeCacheTest, SingleInsertion) {
negativeCache.add(kParent, kName);
EXPECT_EQ(negativeCache.size(), 1U);
}

TEST_F(NegativeCacheTest, RemoveOldestWithLRUUpdate) {
NegativeCache::setGlobalMaxSize(2);
negativeCache.setMaxSize(NegativeCache::getGlobalMaxSize());
EXPECT_EQ(negativeCache.size(), 0U);
negativeCache.add(1, "A");
negativeCache.add(1, "B");
EXPECT_EQ(negativeCache.size(), 2U);
EXPECT_TRUE(negativeCache.lookup(1, "A"));
EXPECT_TRUE(negativeCache.lookup(1, "B"));

negativeCache.add(1, "A");
EXPECT_EQ(negativeCache.size(), 2U);
EXPECT_TRUE(negativeCache.lookup(1, "A"));
EXPECT_TRUE(negativeCache.lookup(1, "B"));

negativeCache.add(1, "C");
EXPECT_EQ(negativeCache.size(), 2U);
EXPECT_TRUE(negativeCache.lookup(1, "A"));
EXPECT_FALSE(negativeCache.lookup(1, "B"));
EXPECT_TRUE(negativeCache.lookup(1, "C"));
}

TEST_F(NegativeCacheTest, LookupSuccess) {
negativeCache.add(kParent, kName);
EXPECT_TRUE(negativeCache.lookup(kParent, kName));
}

TEST_F(NegativeCacheTest, LookupTimeoutOrNotFound) {
NegativeCache::setGlobalTimeoutMs(0);
negativeCache.setTimeoutMs(NegativeCache::getGlobalTimeoutMs());
negativeCache.add(kParent, kName);
EXPECT_FALSE(negativeCache.lookup(kParent, kName));
EXPECT_FALSE(negativeCache.lookup(1, "not-added-to-ncache"));
}

TEST_F(NegativeCacheTest, TimeoutOrMaxSizeChangeClearAll) {
for (auto parent : std::views::iota(1U, kMaxSize)) {
negativeCache.add(parent, kName);
}
EXPECT_EQ(negativeCache.size(), 9U);
NegativeCache::setGlobalTimeoutMs(1001);
negativeCache.add(kParent, kName);
EXPECT_EQ(negativeCache.size(), 1U);
for (auto parent : std::views::iota(1U, kMaxSize)) {
negativeCache.add(parent, kName);
}
EXPECT_EQ(negativeCache.size(), 10U);
NegativeCache::setGlobalMaxSize(11);
negativeCache.add(kParent, kName);
EXPECT_EQ(negativeCache.size(), 1U);
}
Loading