Merged File Cache Implementation Details
This page provides details on the implementation of the Merged File Cache, including API reference and expiration details.
Provided API is not 'final', it's just a plan/rough draft.
The APIs use C# types for simplicity, but should be translateable to other languages.
The Merged File Cache is composed of a two level system.
At the first level is a binary file with a listing of all known caches. Each mod + mod version having their own cache folder.
Top Level View
The entry folder of the cache mod looks like this
.
├── reloaded3.utility.examplemod.s56+1.0.0
│ └── cache.bin
├── reloaded3.utility.examplemod.s56+2.0.0
│ └── cache.bin
└── caches.bin
.
├── reloaded3.utility.examplemod.s56+1.0.0.mdb
│ └── cache.bin
├── reloaded3.utility.examplemod.s56+2.0.0.mdb
│ └── cache.bin
└── caches.bin
We read caches.bin
in order to discover all cache folders.
Then read from the cache folders to get the actual per mod cache.
Each folder is composed of Mod Id and the Mod Version concatenated with a +
.
It is technically possible that this produces a filename which cannot be created on some filesystems.
This should not happen on Linux or Windows, but it may be possible in more niche systems.
For this reason, we store the FolderName
in the Cache
struct.
If a folder with a given name can't be created, we create a folder with a random ASCII name
and store that name in the FolderName
field.
Caches.bin
The caches.bin file is composed of Cache
entries and some metadata.
CachesHeader
Property | Type | Description |
---|---|---|
Version |
int | Version of the cache. Increment on breaking change. |
Caches |
Dictionary<CacheKey, Cache> |
Mapping of CacheKey to Cache . (HashMap) |
If the version field does not match or the file fails to parse, the cache is considered invalid and the entire folder should be wiped.
CacheKey
Struct
Property | Type | Description |
---|---|---|
ModId |
string |
Unique identifier of the mod. |
Version |
SemVer |
Semantic version of the mod. |
Cache
Struct
Property | Type | Description |
---|---|---|
FolderName |
string |
The directory where the cache folder are stored. |
Expiration |
DateTime |
The last access date + predefined amount. |
The Expiration
field is updated every time the cache is accessed. To it, we add the user's chosen
expiration duration, which we default to 14 days.
Deeper Level View
If we further expand the view, we may get something like this:
.
├── reloaded3.utility.examplemod.s56+1.0.0
│ ├── somefolder
│ │ └── cachedfile.bin
│ ├── someotherfolder
│ │ └── anothercachedfile.bin
│ └── cache.bin
├── reloaded3.utility.examplemod.s56+2.0.0
│ ├── somefolder
│ │ └── cachedfile.bin
│ ├── someotherfolder
│ │ └── anothercachedfile.bin
│ └── cache.bin
└── caches.bin
.
├── reloaded3.utility.examplemod.s56+1.0.0.mdb
│ ├── somefolder
│ │ └── cachedfile.bin
│ ├── someotherfolder
│ │ └── anothercachedfile.bin
│ └── cache.bin
├── reloaded3.utility.examplemod.s56+2.0.0.mdb
│ ├── somefolder
│ │ └── cachedfile.bin
│ ├── someotherfolder
│ │ └── anothercachedfile.bin
│ └── cache.bin
└── caches.bin
And if we scope it to a given mod+version's cache folder, we get:
.
├── somefolder
│ └── cachedfile.bin
├── someotherfolder
│ └── anothercachedfile.bin
└── cache.bin
The cache.bin
file contains info of the contents of the cache.
Cache.bin
The cache.bin
file is composed of a dictionary (hashset) of CacheFileKey
to CacheFileEntry
entries.
CacheFileKey
Struct
Property | Type | Description |
---|---|---|
FilePath |
string |
The path to the file relative to folder of cache.bin . |
ModIds |
string[] |
An array of unique mod IDs from which the mods are sourced. |
Timestamps |
DateTime[] |
An array of timestamps representing the last modification times of the source files. |
ModVersions |
string[] |
An array of mod versions representing the versions of the source mods. |
CacheFileEntry
Struct
Represents an individual cached file entry.
Property | Type | Description |
---|---|---|
FilePath |
string |
The path to the file relative to folder of cache.bin . |
Expiration |
DateTime |
The date after which the item should be removed. |
API Reference
ICacheFactory
Interface
This is an abstraction over Caches.bin
Method | Description | Parameters |
---|---|---|
GetOrCreateCache |
Retrieves the cache instance for the specified mod ID and version. If it doesn't exist, creates a new one. | string modId , string modVersion |
This creates an IMergedFileCache.
IMergedFileCache
Interface
This is an abstraction over Cache.bin
Returned from ICacheFactory
Method | Description | Parameters |
---|---|---|
TryGet |
Attempts to retrieve a cached file based on the specified cache key. | CacheFileKey key |
Add |
Adds a merged file to the cache using the specified cache key and content. | CacheFileKey key , byte[] content |
Remove |
Removes a cache entry with the specified cache key. | CacheFileKey key |
Clear |
Removes all cache entries. | - |
RemoveExpiredItems |
Removes all stale cache entries based on the expiration duration. | - |
Files obtained from the cache are read-only
.
Do not modify any file returned by TryGet
.
Usage Example
How using the cache should look in different languages.
// Get the cache factory instance
var cacheFactory = _modLoader.GetService<ICacheFactory>();
// Get the cache instance for the current mod
var cache = cacheFactory.GetOrCreateCache(GetModId(), GetModVersion());
// Define the cache key
var cacheKey = new CacheFileKey
{
FilePath = "path/to/file.txt",
ModIds = new string[] { "mod1", "mod2" },
Timestamps = new DateTime[] { File.GetLastWriteTime("path/to/mod1/file.txt"), File.GetLastWriteTime("path/to/mod2/file.txt") },
ModVersions = new string[] { "1.0.0", "2.1.3" }
};
// Try to get the cached file
if (cache.TryGet(cacheKey, out string cachedFilePath))
{
// Cache hit, use the cached file
string mergedFileContent = File.ReadAllText(cachedFilePath);
}
else
{
// Cache miss, merge the files and cache the result
string mergedFileContent = MergeFiles("path/to/mod1/file.txt", "path/to/mod2/file.txt");
cache.Add(cacheKey, Encoding.UTF8.GetBytes(mergedFileContent));
}
// Get the cache factory instance
let cache_factory = mod_loader.get_service::<ICacheFactory>();
// Get the cache instance for the current mod
let cache = cache_factory.get_or_crate_cache(get_mod_id(), get_mod_version());
// Define the cache key
let cache_key = CacheFileKey {
file_path: "path/to/file.txt".to_string(),
mod_ids: vec!["mod1".to_string(), "mod2".to_string()],
timestamps: vec![
std::fs::metadata("path/to/mod1/file.txt").unwrap().modified().unwrap(),
std::fs::metadata("path/to/mod2/file.txt").unwrap().modified().unwrap(),
],
mod_versions: vec!["1.0.0".to_string(), "2.1.3".to_string()],
};
// Try to get the cached file
if let Some(cached_file_path) = cache.try_get(&cache_key) {
// Cache hit, use the cached file
let merged_file_content = std::fs::read_to_string(cached_file_path).unwrap();
} else {
// Cache miss, merge the files and cache the result
let merged_file_content = merge_files("path/to/mod1/file.txt", "path/to/mod2/file.txt");
cache.add(&cache_key, merged_file_content.as_bytes());
}
// Get the cache factory instance
auto cacheFactory = modLoader->GetService<ICacheFactory>();
// Get the cache instance for the current mod
auto cache = cacheFactory->GetOrCreateCache(GetModId(), GetModVersion());
// Define the cache key
CacheFileKey cacheKey;
cacheKey.FilePath = "path/to/file.txt";
cacheKey.ModIds = { "mod1", "mod2" };
cacheKey.Timestamps = {
std::filesystem::last_write_time("path/to/mod1/file.txt"),
std::filesystem::last_write_time("path/to/mod2/file.txt")
};
cacheKey.ModVersions = { "1.0.0", "2.1.3" };
// Try to get the cached file
std::optional<std::string> cachedFilePath = cache->TryGet(cacheKey);
if (cachedFilePath.has_value()) {
// Cache hit, use the cached file
std::string mergedFileContent = ReadFile(cachedFilePath.value());
} else {
// Cache miss, merge the files and cache the result
std::string mergedFileContent = MergeFiles("path/to/mod1/file.txt", "path/to/mod2/file.txt");
cache->Add(cacheKey, mergedFileContent.data(), mergedFileContent.size());
}
The ICacheFactory
instance is obtained from the mod loader using the GetService
method.
The cache instance for the current mod is then retrieved using the GetOrCreateCache
method of the factory,
passing the current mod's ID and version.
The CacheFileKey
struct is then defined using the file path, mod IDs, timestamps, and mod versions.
The TryGet
method is used to attempt to retrieve the cached file using the CacheFileKey
, and if a
cache miss occurs, the files are merged and added to the cache using the Add
method with the
CacheFileKey
and merged content.
Expiration and Cleanup
The Merged File Cache automatically handles expiration and cleanup of stale cache entries.
When all mods are done loading, the cache will crawl through all the cache entries and remove stale entries.
This can also be force triggered when the RemoveExpiredItems
method is called explicitly.
The directory structure of the cached files mimics the actual file paths used in the cache, in order to minimize the amount of files per directory.
LMDB Implementation Behaviour
The cache returns paths to files, how can it work with a key/value store like LMDB??
Pretty simple, through the File Emulation Framework's Virtual Files API, which in turn also calls Virtual File System's Register Virtual File API.
Using that, we can add a virtual file that is normally accessible for read-only access.
Thread Safety
The merged file cache can have multiple readers or 1 writer at once.
Since the cache is scoped per mod, that effectively means that there will only ever be concurrent access if said mod uses multiple threads for merging at startup.
In other words, TryGet
, can be performed simultaneously by multiple threads without any
synchronization overhead.
But if someone calls Add
, other read/write threads will have to wait until the write
operation is completed.
Regarding Write Lock Duration
Writing the actual file does not lock. Only updating the internal cache state locks, so in practice the cache is pretty much always unlocked.