care pack and configurable

This commit is contained in:
Lordfox
2025-10-04 11:59:11 +02:00
parent 9d6c9068e1
commit 8199daff25
3 changed files with 395 additions and 59 deletions
@@ -0,0 +1,59 @@
# ConcurrentFlotsamAssetCache Console Commands
All commands are available in the "Assets" scope. Use from the OpenSim console.
| Command | Syntax | Description | Notes |
|---|---|---|---|
| Status | cfcache status | Displays cache status, hit-rates, and (when safe) counts the number of files in the cache. | Skips file counting if a deep scan/cleanup is running. |
| Clear | cfcache clear [file] [memory] | Clears cache contents. With no arguments, clears both memory and file caches. | Also clears negative-cache if enabled. |
| Clear Negatives | cfcache clearnegatives | Clears the negative (miss) cache only. | Useful after source issues are fixed to avoid stale “miss” suppression. |
| Assets (Deep Cache) | cfcache assets | Deep scans all scenes, “touches” assets in use, and tries to fetch cache-misses. | Temporarily pauses the periodic cleaner while it runs. Can take long on large scenes. |
| Expire (By Date) | cfcache expire <datetime> | Purges cached files older than the specified date/time. | Accepts “now” or any valid date/time (e.g., 03/15/2025 10:30). Must be in the past. |
| Cache Default Assets | cfcache cachedefaultassets | Loads default assets from the configured asset loader into the cache, replacing any existing entries. | Use with care: may override assets provided by your grid. |
| Delete Default Assets | cfcache deletedefaultassets | Removes default assets previously loaded from the local asset loader from the cache. | Use to force refresh from the upstream grid/service. |
| Clean .bak Files | cfcache cleanbak | Immediately scans the cache directory (including subfolders) and deletes stale .bak files older than the configured age. | Only if BakCleanupEnabled is true. Useful to force cleanup after node crashes. |
## Examples
- Show current cache status:
- cfcache status
- Clear both memory and file caches:
- cfcache clear
- Clear only the memory cache:
- cfcache clear memory
- Clear only the file cache:
- cfcache clear file
- Remove all negative-cache entries:
- cfcache clearnegatives
- Deep scan and pre-cache assets for all scenes:
- cfcache assets
- Expire files older than a given moment:
- cfcache expire 03/01/2025 00:00
- cfcache expire now
- Manage default assets:
- cfcache cachedefaultassets
- cfcache deletedefaultassets
- Manually clean stale backup files:
- cfcache cleanbak
## Operational Notes
- The periodic cleaner runs only when:
- FileCacheEnabled = true
- FileCacheTimeout > 0
- FileCleanupTimer > 0
- At least one region is loaded (RegionLoaded triggered)
- During long operations (assets deep-scan, expire), the cleaner timer is paused and resumed automatically.
- .bak files are created next to the target cache file (under your CacheDirectory). They are deleted either:
- Immediately after a successful atomic replace (when backups are enabled), or
- By the periodic cleaner or the cfcache cleanbak command if they are older than BakCleanupMaxAgeHours.
@@ -117,7 +117,7 @@ namespace OpenSim.Region.CoreModules.Asset
bw.Flush();
}
private static AssetBase DeserializeAsset(Stream stream)
private static AssetBase DeserializeAsset(Stream stream, int maxStringLenBytes, int maxDataLenBytes)
{
using var br = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -127,12 +127,6 @@ namespace OpenSim.Region.CoreModules.Asset
int version = br.ReadInt32();
if (version != AssetFileVersion)
throw new System.Runtime.Serialization.SerializationException("Unsupported cache version");
// limits to protect against corrupted files/DoS
// @todo: consider moving these to a config file
const int MaxStringLen = 1 * 1024 * 1024; // 1 MB per string
const int MaxDataLenMB = 256; // 256 MB asset data
const int MaxDataLen = MaxDataLenMB * 1024 * 1024;
static string ReadStringCapped(BinaryReader r, int cap)
{
@@ -152,14 +146,14 @@ namespace OpenSim.Region.CoreModules.Asset
}
}
string id = ReadStringCapped(br, MaxStringLen);
string name = ReadStringCapped(br, MaxStringLen);
string desc = ReadStringCapped(br, MaxStringLen);
string id = ReadStringCapped(br, maxStringLenBytes);
string name = ReadStringCapped(br, maxStringLenBytes);
string desc = ReadStringCapped(br, maxStringLenBytes);
sbyte type = br.ReadSByte();
uint flags = br.ReadUInt32();
int dataLen = br.ReadInt32();
if (dataLen < 0 || dataLen > MaxDataLen)
if (dataLen < 0 || dataLen > maxDataLenBytes)
throw new System.Runtime.Serialization.SerializationException("Asset data too large or invalid");
byte[] data = dataLen > 0 ? new byte[dataLen] : Array.Empty<byte>();
@@ -206,7 +200,6 @@ namespace OpenSim.Region.CoreModules.Asset
private readonly char[] m_InvalidChars;
private int m_LogLevel = 0;
private ulong m_HitRateDisplay = 100; // How often to display hit statistics, given in requests
private ulong m_Requests;
private ulong m_RequestsForInprogress;
@@ -226,7 +219,12 @@ namespace OpenSim.Region.CoreModules.Asset
// new negative cache is a dictionary of asset ids to the time they were last accessed
private ConcurrentDictionary<string, long> m_negativeCache;
private bool m_negativeCacheEnabled = true;
// Negative cache (miss cache) with optional size limit and pruning
// (keep only sizing/pruning fields here; DO NOT redeclare m_negativeCache or m_negativeCacheEnabled)
private static int m_negativeCacheMaxEntries = 100_000; // configurable cap
private static int m_negativeCachePruneBatch = 5_000; // how many to remove when pruning
// Expiration is expressed in hours for memory cache; negative cache in seconds
private double m_MemoryExpiration = 0.016;
private const double m_DefaultFileExpiration = 48;
@@ -252,7 +250,24 @@ namespace OpenSim.Region.CoreModules.Asset
private static bool m_enableReplaceBackup = false; // optionally keep a backup on atomic replace
private static bool m_enableMoveOverwrite = true; // prefer overwrite move when available
private ulong m_HitRateDisplay = 100; // How often to display hit statistics, given in requests
private int m_HitReportWeakRefSampleTarget = 2000; // configurable sample size for weak refs
// Configurable deserialization caps (defaults conservative)
private int m_MaxStringLenBytes = 256 * 1024; // 256 KB per string
private int m_MaxDataLenMB = 64; // 64 MB asset data
private int m_MaxDataLenBytes => m_MaxDataLenMB * 1024 * 1024;
// Configurable backoff for concurrent write contention
private int m_BackoffAttempts = 3; // number of retries
private int m_BackoffInitialMs = 5; // initial delay in ms
private int m_BackoffMaxMs = 40; // max delay cap in ms
// .bak cleanup options
private bool m_EnableBakCleanup = true;
private TimeSpan m_BakMaxAge = TimeSpan.FromHours(24);
public ConcurrentFlotsamAssetCache()
{
List<char> invalidChars = new();
@@ -304,7 +319,6 @@ namespace OpenSim.Region.CoreModules.Asset
m_updateFileTimeOnCacheHit &= m_FileCacheEnabled;
m_LogLevel = assetConfig.GetInt("LogLevel", m_LogLevel);
m_HitRateDisplay = (ulong)assetConfig.GetLong("HitRateDisplay", (long)m_HitRateDisplay);
m_FileExpiration = TimeSpan.FromHours(assetConfig.GetDouble("FileCacheTimeout", m_DefaultFileExpiration));
m_FileExpirationCleanupTimer = TimeSpan.FromHours(
@@ -318,6 +332,49 @@ namespace OpenSim.Region.CoreModules.Asset
// Optional controls for safer replace behavior
m_enableReplaceBackup = assetConfig.GetBoolean("FileReplaceKeepBackup", m_enableReplaceBackup);
m_enableMoveOverwrite = assetConfig.GetBoolean("FileMoveAllowOverwrite", m_enableMoveOverwrite);
// Negative cache sizing (optional)
m_negativeCacheMaxEntries = assetConfig.GetInt("NegativeCacheMaxEntries", m_negativeCacheMaxEntries);
m_negativeCachePruneBatch = assetConfig.GetInt("NegativeCachePruneBatch", m_negativeCachePruneBatch);
if (m_negativeCacheMaxEntries < 1000) m_negativeCacheMaxEntries = 1000;
if (m_negativeCachePruneBatch < 100) m_negativeCachePruneBatch = 100;
m_HitRateDisplay = (ulong)assetConfig.GetLong("HitRateDisplay", (long)m_HitRateDisplay);
m_HitReportWeakRefSampleTarget = assetConfig.GetInt("HitReportWeakRefSampleTarget", m_HitReportWeakRefSampleTarget);
if (m_HitReportWeakRefSampleTarget < 100)
m_HitReportWeakRefSampleTarget = 100; // lower bound to avoid excessive iteration
// Deserialization limits (optional)
m_MaxStringLenBytes = assetConfig.GetInt("DeserializeMaxStringLenBytes", m_MaxStringLenBytes);
m_MaxDataLenMB = assetConfig.GetInt("DeserializeMaxDataLenMB", m_MaxDataLenMB);
// floors (avoid too small values that break normal assets)
if (m_MaxStringLenBytes < 32 * 1024) m_MaxStringLenBytes = 32 * 1024; // 32 KB
if (m_MaxDataLenMB < 8) m_MaxDataLenMB = 8; // 8 MB
// ceilings (protect from misconfiguration)
if (m_MaxStringLenBytes > 2 * 1024 * 1024) m_MaxStringLenBytes = 2 * 1024 * 1024; // 2 MB
if (m_MaxDataLenMB > 512) m_MaxDataLenMB = 512; // 512 MB
// Backoff tuning (optional)
m_BackoffAttempts = assetConfig.GetInt("BackoffAttempts", m_BackoffAttempts);
m_BackoffInitialMs = assetConfig.GetInt("BackoffInitialMs", m_BackoffInitialMs);
m_BackoffMaxMs = assetConfig.GetInt("BackoffMaxMs", m_BackoffMaxMs);
// sanitize backoff
if (m_BackoffAttempts < 0) m_BackoffAttempts = 0;
if (m_BackoffAttempts > 10) m_BackoffAttempts = 10; // hard upper bound
if (m_BackoffInitialMs < 0) m_BackoffInitialMs = 0;
if (m_BackoffInitialMs > 500) m_BackoffInitialMs = 500;
if (m_BackoffMaxMs < m_BackoffInitialMs) m_BackoffMaxMs = m_BackoffInitialMs;
if (m_BackoffMaxMs > 2000) m_BackoffMaxMs = 2000;
// .bak cleanup config (optional)
m_EnableBakCleanup = assetConfig.GetBoolean("BakCleanupEnabled", m_EnableBakCleanup);
double bakMaxAgeHours = assetConfig.GetDouble("BakCleanupMaxAgeHours", m_BakMaxAge.TotalHours);
if (bakMaxAgeHours < 1.0) bakMaxAgeHours = 1.0; // floor
if (bakMaxAgeHours > 168.0) bakMaxAgeHours = 168.0; // cap at 7 days
m_BakMaxAge = TimeSpan.FromHours(bakMaxAgeHours);
}
if (m_updateFileTimeOnCacheHit)
@@ -359,6 +416,7 @@ namespace OpenSim.Region.CoreModules.Asset
MainConsole.Instance.Commands.AddCommand("Assets", true, "cfcache cachedefaultassets", "cfcache cachedefaultassets", "loads local default assets to cache. This may override grid ones. use with care", HandleConsoleCommand);
MainConsole.Instance.Commands.AddCommand("Assets", true, "cfcache deletedefaultassets", "cfcache deletedefaultassets", "deletes default local assets from cache so they can be refreshed from grid. use with care", HandleConsoleCommand);
}
MainConsole.Instance.Commands.AddCommand("Assets", true, "cfcache cleanbak", "cfcache cleanbak", "Clean stale .bak files from cache now", HandleConsoleCommand);
}
public void PostInitialise() { }
@@ -553,7 +611,11 @@ namespace OpenSim.Region.CoreModules.Asset
{
if (!m_negativeCacheEnabled || string.IsNullOrEmpty(id))
return;
long ttlMs = (long)m_negativeExpiration; // already in ms
// opportunistic prune on insert if over capacity
TryPruneNegativeCacheIfNeeded();
long ttlMs = (long)m_negativeExpiration; // ms
long expiresAt = Environment.TickCount64 + ttlMs;
m_negativeCache[id] = expiresAt;
}
@@ -649,7 +711,7 @@ namespace OpenSim.Region.CoreModules.Asset
if (stream.Length == 0) // Empty file will trigger exception below
return null;
asset = DeserializeAsset(stream);
asset = DeserializeAsset(stream, m_MaxStringLenBytes, m_MaxDataLenBytes);
m_DiskHits++;
}
catch (FileNotFoundException) { }
@@ -746,8 +808,9 @@ namespace OpenSim.Region.CoreModules.Asset
if (m_FileCacheEnabled)
{
// small exponential backoff if a write is in progress
for (int delay = 5, attempts = 0; attempts < 3; attempts++, delay *= 2)
// Configurable exponential backoff if a write is in progress
int delay = m_BackoffInitialMs;
for (int attempts = 0; attempts < m_BackoffAttempts; attempts++)
{
asset = GetFromFileCache(id);
if (asset is not null)
@@ -762,7 +825,10 @@ namespace OpenSim.Region.CoreModules.Asset
if (fname is null || !m_CurrentlyWriting.ContainsKey(fname))
break;
Thread.Sleep(delay);
if (delay > 0)
Thread.Sleep(Math.Min(delay, m_BackoffMaxMs));
// exponential growth with cap
delay = Math.Min(delay == 0 ? 1 : delay * 2, m_BackoffMaxMs);
}
}
return false;
@@ -947,6 +1013,8 @@ namespace OpenSim.Region.CoreModules.Asset
if (kv.Value < now)
m_negativeCache.TryRemove(kv.Key, out _);
}
// also prune if size is above cap (periodic cleanup)
TryPruneNegativeCacheIfNeeded();
}
Dictionary<UUID, sbyte> gids = GatherSceneAssets();
@@ -985,6 +1053,60 @@ namespace OpenSim.Region.CoreModules.Asset
}
}
}
private void TryPruneNegativeCacheIfNeeded()
{
if (!m_negativeCacheEnabled)
return;
int count = m_negativeCache.Count;
if (count <= m_negativeCacheMaxEntries)
return;
long now = Environment.TickCount64;
// 1) remove expired entries opportunistically
int removed = 0;
foreach (var kv in m_negativeCache)
{
if (kv.Value < now)
{
if (m_negativeCache.TryRemove(kv.Key, out _))
{
if (++removed >= m_negativeCachePruneBatch)
break;
}
}
}
// 2) if still over capacity, remove oldest entries
if (m_negativeCache.Count > m_negativeCacheMaxEntries)
{
// sample up to N entries to avoid full sort when map is huge
const int sampleTarget = 5000;
var sample = new List<KeyValuePair<string, long>>(Math.Min(sampleTarget, m_negativeCache.Count));
int step = Math.Max(1, m_negativeCache.Count / Math.Min(sampleTarget, m_negativeCache.Count));
int idx = 0;
foreach (var kv in m_negativeCache)
{
if ((idx++ % step) == 0)
{
sample.Add(kv);
if (sample.Count >= sampleTarget) break;
}
}
// sort by expiration ascending (oldest first)
sample.Sort(static (a, b) => a.Value.CompareTo(b.Value));
int needToRemove = Math.Min(m_negativeCachePruneBatch, Math.Max(0, m_negativeCache.Count - m_negativeCacheMaxEntries));
for (int i = 0; i < sample.Count && needToRemove > 0; i++)
{
if (m_negativeCache.TryRemove(sample[i].Key, out _))
needToRemove--;
}
}
}
/// <summary>
/// Recurses through specified directory checking for asset files last
@@ -1026,6 +1148,28 @@ namespace OpenSim.Region.CoreModules.Asset
if (string.IsNullOrEmpty(id))
continue;
// Optional cleanup of stale .bak files (from interrupted Replace)
if (m_EnableBakCleanup && id.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
{
try
{
DateTime lastWrite = File.GetLastWriteTime(file);
if (DateTime.Now - lastWrite > m_BakMaxAge)
{
File.Delete(file);
--dirSize;
cooldown += 2;
if (cooldown >= 20)
{
Thread.Sleep(60);
cooldown = 0;
}
continue;
}
}
catch { /* ignore */ }
}
if (m_defaultAssets.Contains(id) || (UUID.TryParse(id, out UUID uid) && gids.ContainsKey(uid)))
{
++cooldown;
@@ -1170,7 +1314,7 @@ namespace OpenSim.Region.CoreModules.Asset
m_log.Warn($"[CONCURRENT FLOTSAM ASSET CACHE]: Failed to write asset {asset.ID} to temporary location {tempname} (final {filename}) on cache in {directory}: {e.Message}");
return;
}
catch (UnauthorizedAccessException)
catch (UnauthorizedAccessException e)
{
m_log.Warn($"[CONCURRENT FLOTSAM ASSET CACHE]: Unauthorized writing asset {asset.ID} to {directory}: {e.Message}");
return;
@@ -1456,20 +1600,16 @@ namespace OpenSim.Region.CoreModules.Asset
double weakHitRate = m_weakRefHits * invReq;
// Sample-based alive counting to reduce CPU cost on large maps
// Sample-based alive counting to reduce CPU cost on large maps (configurable)
int weakEntries = weakAssetReferences.Count;
int weakEntriesAliveSampled = 0;
int sampled = 0;
// @todo: add to config
const int sampleTarget = 2000; // sample up to this many entries
int sampleTarget = m_HitReportWeakRefSampleTarget; // from config
int sampleStep = 1;
if (weakEntries > sampleTarget && sampleTarget > 0)
{
// stride to visit ~sampleTarget entries
sampleStep = Math.Max(1, weakEntries / sampleTarget);
}
int index = 0;
foreach (var kv in weakAssetReferences)
@@ -1520,6 +1660,47 @@ namespace OpenSim.Region.CoreModules.Asset
switch (cmd)
{
case "cleanbak":
{
if (!m_FileCacheEnabled)
{
con.Output("File cache not enabled.");
break;
}
if (!m_EnableBakCleanup)
{
con.Output("Bak cleanup disabled by config.");
break;
}
int removed = 0;
try
{
foreach (string dir in Directory.EnumerateDirectories(m_CacheDirectory))
removed += CleanBakInDir(dir);
// also check root for stray .bak files
foreach (string file in Directory.EnumerateFiles(m_CacheDirectory, "*.bak"))
{
try
{
if (DateTime.Now - File.GetLastWriteTime(file) > m_BakMaxAge)
{
File.Delete(file);
removed++;
}
}
catch { }
}
con.Output($"Bak cleanup finished. Removed {removed} files older than {m_BakMaxAge}.");
}
catch (Exception e)
{
con.Output($"Bak cleanup failed: {e.Message}");
}
break;
}
case "status":
{
WorkManager.RunInThreadPool(delegate
@@ -1778,8 +1959,34 @@ namespace OpenSim.Region.CoreModules.Asset
con.Output("cfcache status - Display cache status");
con.Output("cfcache cachedefaultassets - loads default assets to cache replacing existent ones, this may override grid assets. Use with care");
con.Output("cfcache deletedefaultassets - deletes default local assets from cache so they can be refreshed from grid");
con.Output("cfcache cleanbak - Remove stale .bak files from file cache now");
}
}
private int CleanBakInDir(string dir)
{
int removed = 0;
try
{
foreach (string subdir in Directory.EnumerateDirectories(dir))
removed += CleanBakInDir(subdir);
foreach (string file in Directory.EnumerateFiles(dir, "*.bak"))
{
try
{
if (DateTime.Now - File.GetLastWriteTime(file) > m_BakMaxAge)
{
File.Delete(file);
removed++;
}
}
catch { }
}
}
catch { }
return removed;
}
#endregion
@@ -1,45 +1,115 @@
; config-include/ConcurrentFlotsamAssetCache.ini
[AssetCache]
; Enable or disable the file-based cache. If false, assets won't be cached on disk.
; Enable or disable the on-disk cache. If false, nothing is written to disk.
; Default: true
FileCacheEnabled = true
; Root directory where cached asset files are stored. Can be absolute or relative to OpenSim.exe.
; Root directory for cached asset files (absolute or relative to OpenSim.exe).
; Default: c_assetcache
CacheDirectory = c_assetcache
; Enable or disable the in-memory cache for faster lookups.
MemoryCacheEnabled = true
; Memory cache timeout in hours (floating point). Internally converted to seconds.
; Small values keep RAM usage lower; larger values increase hit rate.
MemoryCacheTimeout = 0.25
; Enable negative cache to remember "not found" results for a short time and reduce upstream load.
; Enable or disable the in-memory cache (fastest tier).
; Default: false
MemoryCacheEnabled = false
; Memory cache timeout in hours (float). Internally converted to seconds.
; Small values reduce RAM; larger values may improve hit rate.
; Default: 0.016 (≈ 57.6 seconds)
MemoryCacheTimeout = 0.016
; Enable negative cache to short-circuit repeated misses for a short time.
; Default: true
NegativeCacheEnabled = true
; Negative cache TTL in seconds. During this time, repeated misses for the same ID will be short-circuited.
; Negative cache TTL in seconds.
; Default: 120
NegativeCacheTimeout = 120
; Update the file's last-access time on cache hits (touch). Helps expiration logic but can be expensive on some filesystems.
; This is throttled internally to avoid touching too frequently.
UpdateFileTimeOnCacheHit = true
; Logging detail for this module (0=minimal, 1=info, 2=verbose warnings).
; Optional maximum size for the negative cache to control memory usage.
; When exceeded, a batch of old/expired entries is pruned.
; Default: 100000
NegativeCacheMaxEntries = 100000
; How many entries to remove when pruning the negative cache.
; Default: 5000
NegativeCachePruneBatch = 5000
; Update file LastAccessTime on cache hits (touch). Helps expiration logic,
; but can add IO. Internally throttled.
; Default: false (effective, because it is masked by FileCacheEnabled)
UpdateFileTimeOnCacheHit = false
; Log verbosity for this module (0=minimal, 1=info, 2=verbose warnings).
; Default: 0
LogLevel = 0
; Display hit-rate statistics every N requests (0 disables).
; Show hit-rate statistics every N requests (0 disables).
; Default: 100
HitRateDisplay = 100
; How long (in hours) files may stay in cache since last access before the background cleaner removes them.
; Sampling size used to estimate alive WeakReference entries in hit report.
; Larger values increase accuracy but also CPU work during reporting.
; Default: 2000 (minimum enforced: 100)
HitReportWeakRefSampleTarget = 2000
; Expiration for on-disk files since last access (in hours).
; Files older than now - FileCacheTimeout are eligible for removal.
; Default: 48
FileCacheTimeout = 48
; How often (in hours) the background cleaner runs to purge expired files.
; How often (in hours) the background cleaner runs.
; Default: 1.0
FileCleanupTimer = 1.0
; Directory sharding: number of tier levels (1..3). Increases number of nested directories to keep folders smaller.
; Directory sharding number of tier levels (1..3).
; Default: 1
CacheDirectoryTiers = 1
; Directory sharding: number of characters per tier segment (1..4). Short segments mean more subfolders.
; Directory sharding characters per tier segment (1..4).
; Default: 3
CacheDirectoryTierLength = 3
; Warn if a single cache directory contains more than this many entries (helps tuning tiering/expiration).
CacheWarnAt = 30000
; Warn if a single cache directory contains more than this many entries.
; Helps tune sharding/expiration.
; Default: 30000
CacheWarnAt = 30000
; Safer replace behavior: keep a temporary backup when replacing files (Windows/NTFS).
; Backup files (.bak) are placed beside the target file.
; Default: false
FileReplaceKeepBackup = false
; Allow overwrite moves when supported by the platform/runtime (NET6+).
; Default: true
FileMoveAllowOverwrite = true
; Cleanup of stale .bak files (from interrupted replacements).
; If enabled, the background cleaner removes old .bak files.
; Default: true
BakCleanupEnabled = true
; Max age (in hours) for .bak files before they are removed by the cleaner.
; Default: 24 (range enforced: 1 .. 168)
BakCleanupMaxAgeHours = 24
; Deserialization safety limits (protect against oversized/corrupt cache files):
; Maximum UTF-8 string length per field (bytes). Floors/ceilings enforced internally.
; Default: 262144 (256 KB) [floor: 32768, ceiling: 2097152]
DeserializeMaxStringLenBytes = 262144
; Maximum asset data size (MB). Floors/ceilings enforced internally.
; Default: 64 [floor: 8, ceiling: 512]
DeserializeMaxDataLenMB = 64
; Backoff when a file is being written by another thread (thundering herd control):
; Number of retry attempts.
; Default: 3 [0..10]
BackoffAttempts = 3
; Initial backoff delay in milliseconds (exponential growth per attempt).
; Default: 5 [0..500]
BackoffInitialMs = 5
; Maximum backoff delay per attempt in milliseconds (cap).
; Default: 40 [>= BackoffInitialMs, <= 2000]
BackoffMaxMs = 40