feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,110 @@
using PCL.Core.IO.Storage.Cache.Model;
using PCL.Core.Logging;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
internal class CacheEvictionService(SqliteCacheStorage db, FileCacheStorage files, CacheOptions options)
{
private CancellationTokenSource? _cts;
private Task? _loop;
private readonly object _startLock = new();
public void Start()
{
lock (_startLock)
{
if (_loop is not null)
{
return;
}
_cts = new CancellationTokenSource();
_loop = Task.Run(() => _EvictionLoopAsync(_cts.Token));
}
}
public void Stop()
{
lock (_startLock)
{
if (_cts is not null)
{
_cts.Cancel();
_cts.Dispose();
_cts = null;
}
_loop = null;
}
}
public void CheckThreshold()
{
// NOTE: this method will not be implemented
// because current eviction strategy is enough to keep the cache size under control without checking threshold after each write operation.
}
private async Task _EvictionLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(options.EvictionInterval, ct).ConfigureAwait(false);
await db.DeleteExpiredAsync(DateTime.UtcNow, ct).ConfigureAwait(false);
var stats = await db.GetStatsAsync(ct).ConfigureAwait(false);
var excess = stats.TotalSizeBytes - (options.MaxCacheSize - options.ReserveBytes);
if (excess > 0)
{
await _EvictAsync(excess, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "CacheEviction", $"An error occurred while evicting cache entries:");
}
}
}
private async Task _EvictAsync(long targetBytes, CancellationToken ct)
{
var freed = 0L;
const int batchSize = 50;
while (freed < targetBytes && !ct.IsCancellationRequested)
{
var candidates = await db.GetEvictionCandidatesAsync(batchSize, ct).ConfigureAwait(false);
if (candidates.Count == 0)
{
return;
}
foreach (var can in candidates)
{
if (ct.IsCancellationRequested)
{
return;
}
await db.DeleteAsync(can.CacheKey!, ct).ConfigureAwait(false);
if (can.EntryType is EntryType.FileRef && can.FileHash is not null)
{
await files.ForceDeleteAsync(can.FileHash).ConfigureAwait(false);
}
freed += can.DataSize;
}
}
}
}
public record CacheEntryEvictedEventArgs(string Key, long DataSize, int Priority, long HitCount);
@@ -0,0 +1,98 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace PCL.Core.IO.Storage.Cache;
public class CacheKeys
{
#region Instance
/// <summary>实例元数据缓存键</summary>
public static string InstanceMeta(string instancePath)
=> _Build("instance", "meta", _HashSegment(instancePath));
/// <summary>实例 version.json 缓存键</summary>
public static string InstanceManifest(string instancePath, string versionId)
=> _Build("instance", "manifest", _HashSegment(instancePath), versionId);
/// <summary>实例指定类型的组件缓存键(mods/rp/shader/saves</summary>
public static string InstanceComponents(string instancePath, string compType)
=> _Build("instance", "components", _HashSegment(instancePath), compType);
/// <summary>实例中单个组件文件缓存键</summary>
public static string InstanceComponentFile(string instancePath, string fileHash)
=> _Build("instance", "component", _HashSegment(instancePath), fileHash);
#endregion
#region Download
/// <summary>URL 下载缓存键(去重)</summary>
public static string Download(string url)
=> _Build("download", _HashSegment(url));
/// <summary>Library 文件缓存键</summary>
public static string Library(string mavenGroup, string artifact, string version)
=> _Build("library", mavenGroup, artifact, version);
/// <summary>Asset 索引 JSON 缓存键</summary>
public static string AssetIndex(string url)
=> _Build("assets", "index", _HashSegment(url));
/// <summary>Asset 对象文件缓存键(由哈希定位)</summary>
public static string AssetObject(string assetHash)
=> _Build("assets", "object", assetHash);
#endregion
#region API/Network
/// <summary>API 响应缓存键</summary>
public static string ApiResponse(string source, string url)
=> _Build("http", source, _HashSegment(url));
public static string ApiResponseMeta(string source, string url)
=> _Build("http", "meta", source, _HashSegment(url));
/// <summary>模组市场搜索缓存键</summary>
public static string CompSearch(string source, string query, int page)
=> _Build("comp", "search", source, _HashSegment(query), page.ToString());
/// <summary>皮肤/头像缓存键</summary>
public static string Skin(string url)
=> _Build("skin", _HashSegment(url));
/// <summary>图片文件缓存键</summary>
public static string Image(string url)
=> _Build("image", _HashSegment(url));
/// <summary>新闻/公告缓存键</summary>
public static string News(string url)
=> _Build("news", _HashSegment(url));
#endregion
#region Account
/// <summary>OAuth Token 缓存键(10min 滑动过期)</summary>
public static string AuthToken(string accountId)
=> _Build("auth", "token", accountId);
/// <summary>账户列表缓存键(短 TTL</summary>
public static string AccountList()
=> "accounts:list"; // 固定键,无需哈希
#endregion
private static string _Build(params string[] segments)
=> string.Join(':', segments);
/// <summary>
/// 对动态内容(路径、URL、长字符串)做 SHA256 哈希,取前 32 字符。
/// 静态已知数据(枚举值、类型名)不哈希,保持可读性。
/// </summary>
private static string _HashSegment(string raw)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes).ToLowerInvariant()[..32];
}
}
@@ -0,0 +1,38 @@
using System;
namespace PCL.Core.IO.Storage.Cache;
public record CacheOptions
{
/// <summary>
/// SQLite database file path.
/// </summary>
public required string DatabasePath { get; init; }
/// <summary>
/// File cache root directory. (Used for file-mapped storage mode)
/// </summary>
public required string FileCacheRoot { get; init; }
/// <summary>
/// Max physical cache size. When the total cache size exceeds this limit, the eviction process will be triggered to free up space.<br/>
/// <b>(Default: 2 GiB)</b>
/// </summary>
public long MaxCacheSize { get; init; } = 2L * 1024 * 1024 * 1024; // 2 GiB
/// <summary>
/// SQLite inline storage size limit. Cache entries smaller than or equal to this size will be stored directly in the SQLite database, while larger entries will be stored as file-mapped.<br/>
/// </summary>
public int MaxInlineSize { get; init; } = 256 * 1024; // 256 KiB
/// <summary>
/// Background eviction interval. The cache will automatically check for expired entries and evict them at this interval.<br/>
/// <b>(Default: 5 minutes)</b>
/// </summary>
public TimeSpan EvictionInterval { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Reserve bytes for the cache. This amount of space will always be reserved for the cache, even when eviction is triggered.<br/>
/// <b>(Default: 256 MiB)</b>
/// </summary>
public long ReserveBytes { get; init; } = 256L * 1024 * 1024; // 256 MiB
/// <summary>
/// Whether to enable compression for cache entries. (Enabled by default)<br/>
/// </summary>
public bool EnableCompression { get; init; } = true;
}
@@ -0,0 +1,58 @@
using System;
namespace PCL.Core.IO.Storage.Cache;
/// <summary>
/// Represents the caching policy for managing cached items.<br/>
/// <code>CachePolicy.Default = AbsoluteExpiration = TimeSpan.FromHours(1), Normal (Priority), Auto (StorageMode)</code>
/// </summary>
public record CachePolicy
{
public static readonly CachePolicy Default = new();
public static readonly CachePolicy NeverExpire = new()
{
SlidingExpiration = null,
AbsoluteExpiration = null,
Priority = CachePriority.NeverEvict
};
/// <summary>
/// Gets the absolute expiration time for the cached item. (Calculated from the time the item was cached)<br/>
/// </summary>
public TimeSpan? AbsoluteExpiration { get; init; } = TimeSpan.FromHours(1);
/// <summary>
/// Gets the sliding expiration time for the cached item. (Resets the expiration timer each time the item is accessed)<br/>
/// </summary>
public TimeSpan? SlidingExpiration { get; init; }
/// <summary>
/// Gets the priority of the cached item. (Determines eviction order when the cache needs to free up space)
/// </summary>
public CachePriority Priority { get; init; } = CachePriority.Normal;
/// <summary>
/// Gets the storage mode for the cached item.<br/>
/// Auto &lt;= 256KiB: Inline, &gt; 256KiB: FileMapped
/// </summary>
public CacheStorageMode StorageMode { get; init; } = CacheStorageMode.Auto;
/// <summary>
/// Group. Used for grouping related cached items together, allowing for bulk operations like eviction or retrieval based on the group key.
/// </summary>
public string? Group { get; init; }
/// <summary>
/// Tag. Spilited by <c>','</c>. Used for multi-demension grouping.
/// </summary>
public string? Tags { get; init; }
/// <summary>
/// Content format version. (Increased when migration is needed.)
/// </summary>
public int ContentVersion { get; init; } = 1;
/// <summary>
/// The minimum time to live for the cached item. <br/>
/// Used when the Internet is not stable.<br/>
/// (Overrides Priority and StorageMode when the net is not stable)
/// </summary>
public TimeSpan? MinTimeToLive { get; init; }
}
public enum CachePriority { Low, Normal, High, NeverEvict }
public enum CacheStorageMode { Auto, Inline, FileMapped }
@@ -0,0 +1,24 @@
using System;
namespace PCL.Core.IO.Storage;
/// <summary>
/// The result of a cache lookup, indicating whether the value was found, the value itself if found, and the time it was cached at.
/// </summary>
public readonly record struct CacheResult<T>
{
public static readonly CacheResult<T> Miss = default;
public readonly bool Found;
public readonly T? Value;
public readonly DateTime CachedAt;
public static CacheResult<T> Hit(T value, DateTime cachedAt) => new(value, cachedAt);
private CacheResult(T value, DateTime cachedAt)
{
Found = true;
Value = value;
CachedAt = cachedAt;
}
}
@@ -0,0 +1,461 @@
using Microsoft.Data.Sqlite;
using PCL.Core.App;
using PCL.Core.IO.Storage.Cache.Model;
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
/// <summary>
/// A cache service that provides asynchronous methods to store and retrieve data with support for expiration, tagging, grouping, and priority.<br/>
/// It uses a combination of SQLite for metadata storage and the file system for large data storage.<br/>
/// The service also includes an eviction mechanism to manage cache size and expired entries.<br/>
/// </summary>
public class CacheService : ICacheService, IAsyncDisposable
{
private readonly CacheOptions _options;
private readonly SchemaManager _schemaManager;
private readonly SqliteCacheStorage _db;
private readonly FileCacheStorage _files;
private readonly CacheEvictionService _eviction;
private long _hits;
private long _misses;
private bool _disposed;
private static readonly JsonSerializerOptions _JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public CacheService()
{
_options = new CacheOptions
{
DatabasePath = Path.Combine(Paths.Temp, "Cache", "pcl_ce_cache.db"),
FileCacheRoot = Path.Combine(Paths.Temp, "Cache", "files")
};
_schemaManager = new SchemaManager($"Data Source={_options.DatabasePath}");
_db = new SqliteCacheStorage(_options.DatabasePath);
_files = new FileCacheStorage(_options.FileCacheRoot, _options.EnableCompression);
_eviction = new CacheEvictionService(_db, _files, _options);
}
/// <summary>
/// Unit Test constructor. This constructor allows injecting custom options for testing purposes.
/// </summary>
[Obsolete("This constructor is for testing purposes only.")]
public CacheService(CacheOptions options)
{
_options = options;
_schemaManager = new SchemaManager($"Data Source={_options.DatabasePath}");
_db = new SqliteCacheStorage(_options.DatabasePath);
_files = new FileCacheStorage(_options.FileCacheRoot, _options.EnableCompression);
_eviction = new CacheEvictionService(_db, _files, _options);
}
internal async Task InitializeAsync()
{
Directory.CreateDirectory(_options.FileCacheRoot);
Directory.CreateDirectory(Path.GetDirectoryName(_options.DatabasePath)!);
await _schemaManager.EnsureCurrentSchemaAsync().ConfigureAwait(false);
await _db.CleanupStartupAsync().ConfigureAwait(false);
_eviction.Start();
}
/// <inheritdoc/>
public async Task SetAsync<T>(string key, T value, CachePolicy? policy = null,
CancellationToken ct = default)
{
_ThrowIfNotReady();
policy ??= CachePolicy.Default;
var (bytes, inline) = _Serialize(value);
var entry = new CacheEntry
{
CacheKey = key,
ContentType = typeof(T).Name,
ContentVersion = policy.ContentVersion,
DataSize = bytes.Length,
Tags = policy.Tags ?? string.Empty,
GroupName = policy.Group ?? string.Empty,
Priority = (int)policy.Priority,
ExpiresAt = _ComputeExpiry(policy),
};
if (inline && bytes.Length <= _options.MaxInlineSize)
{
entry = entry with
{
EntryType = EntryType.Inline,
Data = bytes,
ContentHash = _ComputeSha256(bytes)
};
}
else
{
using var ms = new MemoryStream(bytes);
entry = entry with
{
EntryType = EntryType.FileRef,
FileHash = await _files.StoreAsync(ms).ConfigureAwait(false),
ContentHash = _ComputeSha256(bytes)
};
}
await _db.UpsertAsync(entry, ct).ConfigureAwait(false);
_eviction.CheckThreshold();
}
/// <inheritdoc/>
public async Task<CacheResult<T>> GetAsync<T>(string key, CancellationToken ct = default)
{
_ThrowIfNotReady();
var entry = await _db.LookupAsync(key, ct).ConfigureAwait(false);
if (entry is null) { Interlocked.Increment(ref _misses); return CacheResult<T>.Miss; }
// 检查过期
if (entry.ExpiresAt is not null && (DateTime)entry.ExpiresAt < DateTime.UtcNow)
{
await _db.DeleteAsync(key, ct).ConfigureAwait(false);
if (entry.FileHash is not null)
{
await _files.ReleaseAsync(entry.FileHash).ConfigureAwait(false);
}
Interlocked.Increment(ref _misses);
return CacheResult<T>.Miss;
}
Interlocked.Increment(ref _hits);
byte[] data;
// 读取数据
if (entry is { EntryType: EntryType.FileRef, FileHash: not null })
{
await using var stream = _files.Retrieve(entry.FileHash);
if (stream is null)
{
await _db.DeleteAsync(key, ct).ConfigureAwait(false);
Interlocked.Increment(ref _misses);
return CacheResult<T>.Miss;
}
using var ms = new MemoryStream();
await stream.CopyToAsync(ms, ct).ConfigureAwait(false);
data = ms.ToArray();
}
else
{
data = entry.Data ?? [];
}
// 反序列化
var value = _Deserialize<T>(data);
// 更新访问时间
await _db.TouchAsync(key, ct).ConfigureAwait(false);
return CacheResult<T>.Hit(value, entry.CachedAt);
}
/// <inheritdoc/>
public async Task<bool> ExistsAsync(string key)
{
_ThrowIfNotReady();
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
if (entry is null) return false;
return entry.ExpiresAt is null || (DateTime)entry.ExpiresAt >= DateTime.UtcNow;
}
/// <inheritdoc/>
public async Task<bool> DeleteAsync(string key)
{
_ThrowIfNotReady();
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
if (entry is null)
{
return false;
}
if (entry.FileHash is not null)
{
await _files.ReleaseAsync(entry.FileHash).ConfigureAwait(false);
}
return await _db.DeleteAsync(key, CancellationToken.None).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<string?> GetCachedFilePathAsync(string key)
{
var entry = await _db.LookupAsync(key, CancellationToken.None).ConfigureAwait(false);
if (entry?.FileHash is null)
{
return null;
}
return _files.GetFilePath(entry.FileHash);
}
/// <inheritdoc/>
public async Task<string> CacheFileAsync(string key, Stream source,
CachePolicy? policy = null, CancellationToken ct = default)
{
_ThrowIfNotReady();
policy ??= CachePolicy.Default;
var hash = await _files.StoreAsync(source).ConfigureAwait(false);
var entry = new CacheEntry
{
CacheKey = key,
EntryType = EntryType.FileRef,
ContentType = "application/octet-stream",
DataSize = source.Length,
FileHash = hash,
ContentHash = hash,
Tags = policy.Tags ?? "",
GroupName = policy.Group ?? "",
Priority = (int)policy.Priority,
ExpiresAt = _ComputeExpiry(policy),
};
await _db.UpsertAsync(entry, ct).ConfigureAwait(false);
_eviction.CheckThreshold();
return hash;
}
/// <inheritdoc/>
public async Task<int> DeleteByGroupAsync(string groupName)
{
_ThrowIfNotReady();
// 先收集要被删除的文件 hash(用于清理 FileCacheStore
var fileHashes = await _db.GetFileHashesByGroupAsync(groupName, CancellationToken.None).ConfigureAwait(false);
// 逐个释放文件
foreach (var hash in fileHashes)
{
if (hash is not null)
{
await _files.ReleaseAsync(hash).ConfigureAwait(false);
}
}
// 删除数据库中的记录
return await _db.DeleteByGroupAsync(groupName, CancellationToken.None).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task<int> DeleteByTagAsync(string tag)
{
_ThrowIfNotReady();
return _db.DeleteByTagAsync(tag, CancellationToken.None);
}
/// <inheritdoc/>
public async Task<int> DeleteExpiredAsync()
{
_ThrowIfNotReady();
var count = await _db.DeleteExpiredAsync(DateTime.UtcNow, CancellationToken.None).ConfigureAwait(false);
_eviction.CheckThreshold();
return count;
}
/// <inheritdoc/>
public async Task<CacheStats> GetStatsAsync()
{
var row = await _db.GetStatsAsync(CancellationToken.None).ConfigureAwait(false);
return new CacheStats
{
TotalEntries = row.TotalEntries,
TotalSizeBytes = row.TotalSizeBytes,
ExpiredEntries = row.ExpiredEntries,
InlineEntries = row.InlineEntries,
FileEntries = row.FileEntries,
CacheHits = Interlocked.Read(ref _hits),
CacheMisses = Interlocked.Read(ref _misses),
};
}
/// <inheritdoc/>
public async Task ClearAsync()
{
_ThrowIfNotReady();
// 先释放所有文件引用
var fileHashes = await _db.GetAllFileHashesAsync(CancellationToken.None).ConfigureAwait(false);
foreach (var hash in fileHashes)
{
if (hash is not null)
{
await _files.ReleaseAsync(hash).ConfigureAwait(false);
}
}
// 清空所有表
await using var conn = new SqliteConnection($"Data Source={_options.DatabasePath}");
await conn.OpenAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
DELETE FROM cache_entries;
DELETE FROM instance_cache;
DELETE FROM component_cache;
VACUUM;
""";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public Task CompactAsync()
{
_ThrowIfNotReady();
return _db.CompactAsync(CancellationToken.None);
}
#region Event
public event EventHandler<CacheEntryEvictedEventArgs>? EntryEvicted;
internal void OnEntryEvicted(CacheEntryEvictedEventArgs args)
=> EntryEvicted?.Invoke(this, args);
#endregion
#region Helper
private void _ThrowIfNotReady()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private static DateTime? _ComputeExpiry(CachePolicy policy)
{
if (policy.AbsoluteExpiration is null && policy.SlidingExpiration is null)
return null; // 永不过期
if (policy.AbsoluteExpiration is not null)
return DateTime.UtcNow + policy.AbsoluteExpiration;
// 仅滑动过期——初始也设一个基于滑动过期的过期点
return DateTime.UtcNow + policy.SlidingExpiration;
}
private static (byte[] bytes, bool isSmall) _Serialize<T>(T value)
{
return value switch
{
byte[] raw => (raw, raw.Length <= 256 * 1024),
string s => (Encoding.UTF8.GetBytes(s), s.Length <= 256 * 1024),
_ => (JsonSerializer.SerializeToUtf8Bytes(value, _JsonOpts), true),
};
}
private static T _Deserialize<T>(byte[] data)
{
if (typeof(T) == typeof(byte[]))
return (T)(object)data;
if (typeof(T) == typeof(string))
return (T)(object)Encoding.UTF8.GetString(data);
return JsonSerializer.Deserialize<T>(data, _JsonOpts)!;
}
private static string _ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
#endregion
#region Instance/Component
public Task UpsertInstanceAsync(InstanceCacheRow row, CancellationToken ct = default)
=> _db.UpsertInstanceAsync(row, ct);
public Task<InstanceCacheRow?> LookupInstanceAsync(string instancePath, CancellationToken ct = default)
=> _db.LookupInstanceAsync(instancePath, ct);
public Task DeleteInstanceAsync(string instancePath, CancellationToken ct = default)
=> _db.DeleteInstanceAsync(instancePath, ct);
public Task UpsertComponentAsync(ComponentCacheRow row, CancellationToken ct = default)
=> _db.UpsertComponentAsync(row, ct);
public Task<List<ComponentCacheRow>> GetComponentsByInstanceAsync(
string instancePath, string compType, CancellationToken ct = default)
=> _db.GetComponentsByInstanceAsync(instancePath, compType, ct);
public Task<ComponentCacheRow?> GetComponentAsync(
string instancePath, string compType, string fileName, CancellationToken ct = default)
=> _db.GetComponentAsync(instancePath, compType, fileName, ct);
public Task DeleteComponentsByInstanceAsync(string instancePath, CancellationToken ct = default)
=> _db.DeleteComponentsByInstanceAsync(instancePath, ct);
public Task<string?> GetComponentScanHashAsync(
string instancePath, string compType, CancellationToken ct = default)
=> _db.GetComponentScanHashAsync(instancePath, compType, ct);
#endregion
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_eviction.Stop();
try
{
await using var conn = new SqliteConnection($"Data Source={_options.DatabasePath}");
await conn.OpenAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
catch { /* ignore */ }
await CastAndDispose(_db).ConfigureAwait(false);
await CastAndDispose(_files).ConfigureAwait(false);
return;
static async ValueTask CastAndDispose(IDisposable resource)
{
if (resource is IAsyncDisposable resourceAsyncDisposable)
await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false);
else
resource.Dispose();
}
}
}
@@ -0,0 +1,42 @@
using PCL.Core.App.IoC;
using System;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
[LifecycleScope("global_cache", "磁盘缓存服务")]
[LifecycleService(LifecycleState.Loading, Priority = 500)]
public partial class CacheServiceManager
{
private static CacheService? _service;
/// <summary>
/// 获取当前缓存服务实例,如果尚未初始化则为 null
/// </summary>
public static CacheService Current => _service ?? throw new InvalidOperationException("Cache service is not initialized yet.");
[LifecycleStart]
private static async Task _StartAsync()
{
Context.Debug("正在初始化缓存服务...");
_service = new CacheService();
await _service.InitializeAsync().ConfigureAwait(false);
Context.Info("缓存服务初始化成功");
}
[LifecycleStop]
private static async Task _StopAsync()
{
Context.Debug("正在停止缓存服务...");
if (_service != null)
{
await _service.DisposeAsync().ConfigureAwait(false);
_service = null;
}
Context.Info("缓存服务已停止");
}
}
@@ -0,0 +1,16 @@
using System;
namespace PCL.Core.IO.Storage.Cache;
public record CacheStats
{
public long TotalEntries { get; init; }
public long TotalSizeBytes { get; init; }
public long ExpiredEntries { get; init; }
public long InlineEntries { get; init; }
public long FileEntries { get; init; }
public long CacheHits { get; init; }
public long CacheMisses { get; init; }
public double HitRate => (CacheHits + CacheMisses) > 0 ? (double)CacheHits / (CacheHits + CacheMisses) : 0;
public DateTime? LastCleanup { get; init; }
}
@@ -0,0 +1,95 @@
using PCL.Core.Utils.Hash;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
public class FileCacheStorage : IDisposable
{
private readonly HashStorage _hashStorage;
private readonly string _basePath;
private readonly ConcurrentDictionary<string, int> _refCounts = [];
public FileCacheStorage(string cacheRoot, bool enableCompression = true)
{
_basePath = cacheRoot;
Directory.CreateDirectory(cacheRoot);
_hashStorage = new HashStorage(
cacheRoot,
SHA256Provider.Instance,
compressObjects: enableCompression,
correctMisplacedFile: false,
prefixLength: 2);
}
public async Task<string> StoreAsync(Stream source, string? knownHash = null)
{
var hash = await _hashStorage.PutAsync(source, knownHash).ConfigureAwait(false);
if (hash is not null)
{
_refCounts.AddOrUpdate(hash, 1, (_, count) => count + 1);
}
return hash!;
}
public Stream? Retrieve(string hash) => _hashStorage.Get(hash);
public string? GetFilePath(string hash)
{
var prefix = hash[..2];
var paht = Path.Combine(_basePath, prefix, hash);
return File.Exists(paht) ? paht : null;
}
public bool Exists(string hash) => _hashStorage.Exists(hash);
public async Task<bool> ReleaseAsync(string hash)
{
var spin = new SpinWait();
while (true)
{
if (!_refCounts.TryGetValue(hash, out var count))
{
return false;
}
switch (TryRelease(hash, count))
{
case ReleaseResult.Removed:
return await _hashStorage.DeleteAsync(hash).ConfigureAwait(false);
case ReleaseResult.Decremented:
return true;
}
if (spin.NextSpinWillYield)
await Task.Yield();
else
spin.SpinOnce();
}
}
private enum ReleaseResult { Retry, Decremented, Removed }
private ReleaseResult TryRelease(string hash, int expected) =>
expected > 1
? (_refCounts.TryUpdate(hash, expected - 1, expected) ? ReleaseResult.Decremented : ReleaseResult.Retry)
: (_refCounts.TryRemove(new KeyValuePair<string, int>(hash, expected)) ? ReleaseResult.Removed : ReleaseResult.Retry);
public Task<bool> ForceDeleteAsync(string hash)
{
_refCounts.TryRemove(hash, out _);
return _hashStorage.DeleteAsync(hash);
}
/// <inheritdoc />
public void Dispose()
{
_refCounts.Clear();
}
}
@@ -0,0 +1,78 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
/// <summary>
/// Provides a contract for a cache service.
/// </summary>
public interface ICacheService
{
/// <summary>
/// Write a value to the cache with the specified key and optional cache policy. If the key already exists, it will be overwritten.<br/>
/// Small data ( &lt; 256KB) is storaged in SQLite inline, while larger data is storaged as file-mapped.
/// </summary>
Task SetAsync<T>(string key, T value, CachePolicy? policy = null, CancellationToken ct = default);
/// <summary>
/// Read the cache.
/// </summary>
/// <returns>The <see cref="CacheResult{T}"/> the tell 'Miss', 'Hit' or 'Expired'</returns>
Task<CacheResult<T>> GetAsync<T>(string key, CancellationToken ct = default);
/// <summary>
/// Check if a cache entry exists for the specified key.
/// </summary>
/// <param name="key"></param>
/// <returns><see langword="true"/> if the entry exists and is not expired, <see langword="false"/> otherwise</returns>
Task<bool> ExistsAsync(string key);
/// <summary>
/// Deletes the cache entry for the specified key.
/// </summary>
/// <param name="key"></param>
/// <returns><see langword="true"/> if the entry was found and deleted, <see langword="false"/> otherwise</returns>
Task<bool> DeleteAsync(string key);
/// <summary>
/// Gets the file path for the cached entry with the specified key.
/// </summary>
/// <returns>The file path if the entry exists, otherwise null</returns>
Task<string?> GetCachedFilePathAsync(string key);
/// <summary>
/// Cache a file stream with the specified key and optional cache policy. If the key already exists, it will be overwritten.<br/>
/// </summary>
/// <returns>The file path</returns>
Task<string> CacheFileAsync(string key, Stream source, CachePolicy? policy = null, CancellationToken ct = default);
/// <summary>
/// Deletes all cache entries belonging to the specified group.
/// </summary>
/// <returns>The number of entries deleted</returns>
Task<int> DeleteByGroupAsync(string groupName);
/// <summary>
/// Deletes all cache entries with the specified tag.
/// </summary>
/// <param name="tag"></param>
/// <returns>The number of entries deleted</returns>
Task<int> DeleteByTagAsync(string tag);
/// <summary>
/// Deletes all expired cache entries.
/// </summary>
/// <returns>The number of entries deleted</returns>
Task<int> DeleteExpiredAsync();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns></returns>
Task<CacheStats> GetStatsAsync();
/// <summary>
/// Clear all cache entries. Use with caution as this will remove all cached data regardless of expiration or priority.
/// </summary>
Task ClearAsync();
/// <summary>
/// Compacts the cache by removing any unused space.
/// </summary>
Task CompactAsync();
}
@@ -0,0 +1,58 @@
using System;
namespace PCL.Core.IO.Storage.Cache.Model;
public record CacheEntry
{
/// <summary>
/// SHA-256 (Raw key)
/// </summary>
public string CacheKey { get; init; } = string.Empty;
public EntryType EntryType { get; init; }
/// <summary>
/// MIME
/// </summary>
public string ContentType { get; init; } = string.Empty;
/// <summary>
/// Cache data (Used in <see cref="EntryType.Inline"/> mode)
/// </summary>
public byte[]? Data { get; init; }
/// <summary>
/// Original data size
/// </summary>
public long DataSize { get; init; }
/// <summary>
/// File hash (SHA-256) (Used in <see cref="EntryType.FileRef"/> mode)
/// </summary>
public string? FileHash { get; init; }
/// <summary>
/// File path (Relative in cache directory) (Used in <see cref="EntryType.FileRef"/> mode)
/// </summary>
public string? FilePath { get; init; }
public string? ContentHash { get; init; }
/// <summary>
/// Content format version (Used for migration.)
/// </summary>
public int ContentVersion { get; init; }
public DateTime CachedAt { get; init; }
public DateTime LastAccessAt { get; init; }
public DateTime? ExpiresAt { get; init; }
public long HitCount { get; init; }
public string Tags { get; init; } = string.Empty;
public string GroupName { get; init; } = string.Empty;
/// <summary>
/// <c>0 = Low; 1 = Normal; 2 = High; 3 = NeverEvict</c>
/// </summary>
public int Priority { get; init; }
}
public enum EntryType
{
Inline = 0,
FileRef = 1
}
@@ -0,0 +1,29 @@
using System;
namespace PCL.Core.IO.Storage.Cache.Model;
public record ComponentCacheRow
{
public string InstancePath { get; init; } = string.Empty; // PK 1
public string CompType { get; init; } = string.Empty; // PK 2: mods/rp/shader/saves/datapack
public string FileName { get; init; } = string.Empty; // PK 3
public string RelativePath { get; init; } = string.Empty;
public string? FileHash { get; init; } // SHA256
public long FileSize { get; init; }
public DateTime LastModified { get; init; }
public bool Enabled { get; init; }
// Mod 特有(JSON 序列化到 ModMetadata 列)
public string? ModName { get; init; }
public string? ModVersion { get; init; }
public string? ModAuthor { get; init; }
public string? ModDescription { get; init; }
public string? ModLoader { get; init; } // forge / fabric / quilt / liteloader
public string? ModDependencies { get; init; } // JSON array
// Scan metadata
public int CacheVersion { get; init; } = 7;
public DateTime ScannedAt { get; init; }
public string? ScanHash { get; init; } // 目录扫描指纹
}
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace PCL.Core.IO.Storage.Cache.Model;
public record InstanceCacheRow
{
public string InstancePath { get; init; } = string.Empty;
public string InstanceName { get; init; } = string.Empty;
public string InstanceState { get; init; } = "Error";
public int CardType { get; init; }
public bool IsStarred { get; init; }
public string? Logo { get; init; }
public string? Description { get; init; }
public string? ReleaseTime { get; init; }
public string? VanillaName { get; init; }
public string? VanillaVersion { get; init; }
public int DropNumber { get; init; }
public bool Reliable { get; init; }
///<summary>
/// 加载器版本(统一 JSON 存储,替代 n 组 HasXxx + XxxVersion 字段)<br/>
/// 格式:[{"type":"forge","version":"47.2.0"},{"type":"fabric","version":"0.15.0"}]<br/>
/// 空数组 = 无加载器
/// </summary>
public string LoaderJson { get; internal set; } = "[]";
/// <summary>解析加载器列表(反序列化 LoaderJson</summary>
public List<LoaderEntry> GetLoaders() =>
JsonSerializer.Deserialize<List<LoaderEntry>>(LoaderJson) ?? [];
/// <summary>快速检查是否存在指定类型的加载器</summary>
public bool HasLoader(string type) =>
GetLoaders().Any(l => l.Type == type);
/// <summary>获取指定加载器的版本(不存在返回 null</summary>
public string? GetLoaderVersion(string type) =>
GetLoaders().FirstOrDefault(l => l.Type == type)?.Version;
/// <summary>将加载器列表序列化为 JSON 赋给 LoaderJson</summary>
public void SetLoaders(List<LoaderEntry> loaders) =>
LoaderJson = JsonSerializer.Serialize(loaders);
// Manifest summary
public string? MainClass { get; init; }
public string? AssetsIndex { get; init; }
public string? InheritsFrom { get; init; }
public int? JavaVersion { get; init; }
// Cache control
public string SourceJsonHash { get; init; } = string.Empty; // version.json SHA256
public int FormatVersion { get; init; } = 1;
public DateTime CachedAt { get; init; }
public DateTime? LastLoadedAt { get; init; }
}
/// <summary>
/// 加载器版本条目——统一模型替代 N 组 HasXxx/XxxVersion 属性。
/// 新增加载器类型只需在此记录中添加新条目,无需修改表结构或模型类。
/// </summary>
public record LoaderEntry
{
/// <summary>加载器类型标识,如 "forge" / "fabric" / "quilt" / "neoforge" / "liteloader" / "optifine" / "labymod" / "cleanroom" / "legacyfabric"</summary>
public string Type { get; init; } = string.Empty;
/// <summary>加载器版本号(空字符串 = 已检测到加载器但版本未知,null = 不存在)</summary>
public string? Version { get; init; }
}
@@ -0,0 +1,210 @@
using Microsoft.Data.Sqlite;
using System;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
public class SchemaManager(string connectionString)
{
private const int CurrentSchemaVersion = 1;
/// <exception cref="InvalidOperationException">Invalid schema version</exception>
public async Task EnsureCurrentSchemaAsync()
{
await using var conn = new SqliteConnection(connectionString);
await conn.OpenAsync().ConfigureAwait(false);
await _ExecutePragmaAsync(conn, "journal_mode", "WAL").ConfigureAwait(false);
await _ExecutePragmaAsync(conn, "synchronous", "NORMAL").ConfigureAwait(false);
await _ExecutePragmaAsync(conn, "cache_size", "-8000").ConfigureAwait(false); // 8MB page cache
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)
""";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
var currentVersion = 0;
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "SELECT value FROM cache_meta WHERE key = 'schema_version'";
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
if (result is not null)
{
currentVersion = int.Parse(result.ToString() ?? throw new InvalidOperationException("Invalid schema version"));
}
}
if (currentVersion < CurrentSchemaVersion)
{
// NOTE: this method is ApplyMigrations, not _ApplyMigrationAsync. see the 's' difference
await _ApplyMigrationsAsync(conn, currentVersion).ConfigureAwait(false);
}
await _EnsureAllTablesExistAsync(conn).ConfigureAwait(false);
}
private static async Task<int> _ExecutePragmaAsync(SqliteConnection conn, string pragma, string value)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA {pragma} = {value}";
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region Version Upgrade
private async Task _ApplyMigrationsAsync(SqliteConnection conn, int fromVersion)
{
var v = fromVersion;
while (v < CurrentSchemaVersion)
{
v++;
await using var tx = conn.BeginTransaction();
try
{
await _ApplyMigrationAsync(conn, v).ConfigureAwait(false);
await using var setVer = conn.CreateCommand();
setVer.CommandText = "INSERT OR REPLACE INTO cache_meta (key, value) VALUES ('schema_version', @v)";
setVer.Parameters.AddWithValue("@v", v);
await setVer.ExecuteNonQueryAsync().ConfigureAwait(false);
await tx.CommitAsync().ConfigureAwait(false);
}
catch
{
await tx.RollbackAsync().ConfigureAwait(false);
throw;
}
}
}
private async Task _ApplyMigrationAsync(SqliteConnection conn, int targetVersion)
{
switch (targetVersion)
{
case 1:
await _ExecuteDdlAsync(conn, DdlV1CreateAllTables).ConfigureAwait(false);
await _ExecuteDdlAsync(conn, DdlV1CreateIndexes).ConfigureAwait(false);
break;
// NOTE: add future migrations here
// e.g.
// case 2:
// await _ExecuteDdlAsync(conn, "some sql").
}
}
#endregion
private async Task<int> _EnsureAllTablesExistAsync(SqliteConnection conn)
{
await _ExecuteDdlAsync(conn, DdlV1CreateAllTables).ConfigureAwait(false);
await _ExecuteDdlAsync(conn, DdlV1CreateIndexes).ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO cache_meta (key, value) VALUES ('schema_version', @v)";
cmd.Parameters.AddWithValue("@v", CurrentSchemaVersion);
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
private static async Task<int> _ExecuteDdlAsync(SqliteConnection conn, string ddl)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = ddl;
return await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region DDL Text
private const string DdlV1CreateAllTables = """
CREATE TABLE IF NOT EXISTS cache_entries (
cache_key TEXT NOT NULL PRIMARY KEY,
entry_type INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
data BLOB,
data_size INTEGER NOT NULL DEFAULT 0,
file_hash TEXT,
file_path TEXT,
content_hash TEXT,
content_version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_access_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
hit_count INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '',
group_name TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS instance_cache (
instance_path TEXT NOT NULL PRIMARY KEY,
instance_name TEXT NOT NULL,
instance_state TEXT NOT NULL DEFAULT 'Error',
card_type INTEGER NOT NULL DEFAULT 0,
is_starred INTEGER NOT NULL DEFAULT 0,
logo TEXT,
description TEXT,
release_time TEXT,
vanilla_name TEXT,
vanilla_version TEXT,
drop_number INTEGER NOT NULL DEFAULT 0,
reliable INTEGER NOT NULL DEFAULT 1,
loaders_json TEXT NOT NULL DEFAULT '[]',
main_class TEXT,
assets_index TEXT,
inherits_from TEXT,
java_version INTEGER,
source_json_hash TEXT NOT NULL,
format_version INTEGER NOT NULL DEFAULT 1,
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
last_loaded_at TEXT
);
CREATE TABLE IF NOT EXISTS component_cache (
instance_path TEXT NOT NULL,
comp_type TEXT NOT NULL,
file_name TEXT NOT NULL,
relative_path TEXT NOT NULL,
file_hash TEXT,
file_size INTEGER NOT NULL DEFAULT 0,
last_modified TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
mod_name TEXT,
mod_version TEXT,
mod_author TEXT,
mod_description TEXT,
mod_loader TEXT,
mod_deps TEXT,
cache_version INTEGER NOT NULL DEFAULT 7,
scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
scan_hash TEXT,
PRIMARY KEY (instance_path, comp_type, file_name)
);
CREATE TABLE IF NOT EXISTS cache_stats (
stat_name TEXT NOT NULL PRIMARY KEY,
stat_value INTEGER NOT NULL DEFAULT 0
);
""";
private const string DdlV1CreateIndexes = """
CREATE INDEX IF NOT EXISTS idx_ce_expires ON cache_entries(expires_at);
CREATE INDEX IF NOT EXISTS idx_ce_group ON cache_entries(group_name);
CREATE INDEX IF NOT EXISTS idx_ce_tags ON cache_entries(tags);
CREATE INDEX IF NOT EXISTS idx_ce_access ON cache_entries(last_access_at);
CREATE INDEX IF NOT EXISTS idx_ce_priority ON cache_entries(priority);
CREATE INDEX IF NOT EXISTS idx_ce_hits ON cache_entries(hit_count);
CREATE INDEX IF NOT EXISTS idx_cc_instance ON component_cache(instance_path);
CREATE INDEX IF NOT EXISTS idx_cc_scanhash ON component_cache(instance_path, comp_type, scan_hash);
""";
#endregion
}
@@ -0,0 +1,619 @@
using Microsoft.Data.Sqlite;
using PCL.Core.IO.Storage.Cache.Model;
using System;
using System.Globalization;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.IO.Storage.Cache;
public class SqliteCacheStorage(string dbPath) : IDisposable
{
private readonly string _connectionString = $"Data Source={dbPath};Pooling=True";
private readonly SemaphoreSlim _writeLock = new(1, 1);
private bool _disposed;
private async Task<SqliteConnection> _CreateConnectionAsync()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SqliteCacheStorage));
}
var conn = new SqliteConnection(_connectionString);
// NOTE: WAL mode is managed in SchemaManager, so we don't set it here.
// but new connection may need re-enable some session-level pragmas
// but we don't have any for now, so we just return the connection.
await conn.OpenAsync().ConfigureAwait(false);
return conn;
}
public async Task CleanupStartupAsync()
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < datetime('now')";
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
await using var walCmd = conn.CreateCommand();
walCmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)";
await walCmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
#region cache_entries
public async Task UpsertAsync(CacheEntry entry, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO cache_entries (
cache_key, entry_type, content_type, data, data_size, file_hash, file_path,
content_hash, content_version, created_at, last_access_at, expires_at,
hit_count, tags, group_name, priority
) VALUES (
@cache_key, @entry_type, @content_type, @data, @data_size, @file_hash, @file_path,
@content_hash, @content_version, COALESCE((SELECT created_at FROM cache_entries WHERE cache_key = @cache_key), datetime('now')),
datetime('now'), @expires_at, 0, @tags, @group_name, @priority
)
""";
_BindEntryParams(cmd, entry);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<CacheEntry?> LookupAsync(string cacheKey, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM cache_entries WHERE cache_key = @cache_key";
cmd.Parameters.AddWithValue("@cache_key", cacheKey);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadEntry(reader) : null;
}
public async Task TouchAsync(string key, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE cache_entries SET last_access_at = datetime('now'), hit_count = hit_count + 1 WHERE cache_key = @cache_key
""";
cmd.Parameters.AddWithValue("@cache_key", key);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<bool> DeleteAsync(string cacheKey, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE cache_key = @cache_key";
cmd.Parameters.AddWithValue("@cache_key", cacheKey);
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected > 0;
}
finally
{
_writeLock.Release();
}
}
public async Task<int> DeleteExpiredAsync(DateTime now, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now')";
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected;
}
finally
{
_writeLock.Release();
}
}
public async Task<int> DeleteByGroupAsync(string groupName, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE group_name = @group_name";
cmd.Parameters.AddWithValue("@group_name", groupName);
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected;
}
finally
{
_writeLock.Release();
}
}
public async Task<List<string?>> GetFileHashesByGroupAsync(string groupName, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT DISTINCT file_hash FROM cache_entries WHERE group_name = @group_name AND file_hash IS NOT NULL";
cmd.Parameters.AddWithValue("@group_name", groupName);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var hashes = new List<string?>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
hashes.Add(reader.GetString(0));
}
return hashes;
}
public async Task<List<string?>> GetAllFileHashesAsync(CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT DISTINCT file_hash FROM cache_entries WHERE file_hash IS NOT NULL";
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var hashes = new List<string?>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
hashes.Add(reader.GetString(0));
}
return hashes;
}
public async Task<int> DeleteByTagAsync(string tag, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM cache_entries WHERE tags = @t OR tags LIKE @p1 OR tags LIKE @p2 OR tags LIKE @p3";
cmd.Parameters.AddWithValue("@t", tag);
cmd.Parameters.AddWithValue("@p1", $"{tag},%");
cmd.Parameters.AddWithValue("@p2", $"%,{tag},%");
cmd.Parameters.AddWithValue("@p3", $"%,{tag}");
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return affected;
}
finally
{
_writeLock.Release();
}
}
public async Task<CacheStatsRow> GetStatsAsync(CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) AS total, COALESCE(SUM(data_size), 0) AS total_size, COALESCE(SUM(CASE WHEN expires_at < datetime('now') THEN 1 ELSE 0 END), 0) AS expired, COALESCE(SUM(CASE WHEN entry_type = 0 THEN 1 ELSE 0 END), 0) AS inline, COALESCE(SUM(CASE WHEN entry_type = 1 THEN 1 ELSE 0 END), 0) AS file_ref FROM cache_entries";
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
if (await reader.ReadAsync(ct).ConfigureAwait(false))
{
return new CacheStatsRow(
TotalEntries: reader.GetInt64(0),
TotalSizeBytes: reader.GetInt64(1),
ExpiredEntries: reader.GetInt64(2),
InlineEntries: reader.GetInt64(3),
FileEntries: reader.GetInt64(4)
);
}
// none
return new CacheStatsRow(0, 0, 0, 0, 0);
}
public async Task<List<EvictionCandidate>> GetEvictionCandidatesAsync(int limit, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT cache_key, entry_type, file_hash, data_size, priority, hit_count
FROM cache_entries
WHERE priority < 3 -- NeverEvict
ORDER BY priority ASC, hit_count ASC, last_access_at ASC
LIMIT @limit
""";
cmd.Parameters.AddWithValue("@limit", limit);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var list = new List<EvictionCandidate>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(new EvictionCandidate(
CacheKey: reader.GetString(0),
EntryType: (EntryType)reader.GetInt32(1),
FileHash: reader.IsDBNull(2) ? null : reader.GetString(2),
DataSize: reader.GetInt64(3),
Priority: reader.GetInt32(4),
HitCount: reader.GetInt64(5)
));
}
return list;
}
public async Task CompactAsync(CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM";
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
#endregion
#region instance_cache
public async Task UpsertInstanceAsync(InstanceCacheRow row, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT OR REPLACE INTO instance_cache
(instance_path, instance_name, instance_state, card_type, is_starred,
logo, description, release_time,
vanilla_name, vanilla_version, drop_number, reliable,
loaders_json,
main_class, assets_index, inherits_from, java_version,
source_json_hash, format_version, cached_at, last_loaded_at)
VALUES
(@p, @n, @st, @cty, @is,
@lo, @de, @rt,
@vn, @vv, @dn, @re,
@lj,
@mc, @ai, @if, @jv,
@sh, @fv, datetime('now'), datetime('now'))
""";
_BindInstanceParams(cmd, row);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<InstanceCacheRow?> LookupInstanceAsync(string instancePath, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM instance_cache WHERE instance_path = @p";
cmd.Parameters.AddWithValue("@p", instancePath);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadInstanceRow(reader) : null;
}
public async Task<int> DeleteInstanceAsync(string instancePath, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM instance_cache WHERE instance_path = @p";
cmd.Parameters.AddWithValue("@p", instancePath);
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
#endregion
#region component_cache
public async Task UpsertComponentAsync(ComponentCacheRow row, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"
INSERT OR REPLACE INTO component_cache
(instance_path, comp_type, file_name, relative_path,
file_hash, file_size, last_modified, enabled,
mod_name, mod_version, mod_author, mod_description,
mod_loader, mod_deps,
cache_version, scanned_at, scan_hash)
VALUES
(@ip, @ct, @fn, @rp,
@fh, @fs, @lm, @en,
@mn, @mv, @ma, @md,
@ml, @mdeps,
@cv, datetime('now'), @sh)";
_BindComponentParams(cmd, row);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<List<ComponentCacheRow>> GetComponentsByInstanceAsync(
string instancePath, string compType, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM component_cache WHERE instance_path = @ip AND comp_type = @ct";
cmd.Parameters.AddWithValue("@ip", instancePath);
cmd.Parameters.AddWithValue("@ct", compType);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
var list = new List<ComponentCacheRow>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(_ReadComponentRow(reader));
}
return list;
}
public async Task<ComponentCacheRow?> GetComponentAsync(
string instancePath, string compType, string fileName, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM component_cache WHERE instance_path = @ip AND comp_type = @ct AND file_name = @fn";
cmd.Parameters.AddWithValue("@ip", instancePath);
cmd.Parameters.AddWithValue("@ct", compType);
cmd.Parameters.AddWithValue("@fn", fileName);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? _ReadComponentRow(reader) : null;
}
public async Task<int> DeleteComponentsByInstanceAsync(string instancePath, CancellationToken ct)
{
await _writeLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM component_cache WHERE instance_path = @ip";
cmd.Parameters.AddWithValue("@ip", instancePath);
return await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
public async Task<string?> GetComponentScanHashAsync(
string instancePath, string compType, CancellationToken ct)
{
await using var conn = await _CreateConnectionAsync().ConfigureAwait(false);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT DISTINCT scan_hash
FROM component_cache
WHERE instance_path = @ip AND comp_type = @ct AND scan_hash IS NOT NULL
ORDER BY scanned_at DESC
LIMIT 1
""";
cmd.Parameters.AddWithValue("@ip", instancePath);
cmd.Parameters.AddWithValue("@ct", compType);
var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result as string;
}
#endregion
#region Arg Binding
private static void _BindEntryParams(SqliteCommand cmd, CacheEntry e)
{
cmd.Parameters.AddWithValue("@cache_key", e.CacheKey);
cmd.Parameters.AddWithValue("@entry_type", e.EntryType);
cmd.Parameters.AddWithValue("@content_type", e.ContentType);
cmd.Parameters.AddWithValue("@data", (object?)e.Data ?? DBNull.Value);
cmd.Parameters.AddWithValue("@data_size", e.DataSize);
cmd.Parameters.AddWithValue("@file_hash", (object?)e.FileHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@file_path", (object?)e.FilePath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@content_hash", (object?)e.ContentHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@content_version", e.ContentVersion);
cmd.Parameters.AddWithValue("@expires_at", e.ExpiresAt is not null ? ((DateTime)e.ExpiresAt).ToString("O") : DBNull.Value);
cmd.Parameters.AddWithValue("@tags", e.Tags);
cmd.Parameters.AddWithValue("@group_name", e.GroupName);
cmd.Parameters.AddWithValue("@priority", e.Priority);
}
// 数据库中的时间以 ISO-8601 往返格式("O"UTC 带 Z)写入;读取必须用 RoundtripKind 解析,
// 否则 DateTime.Parse 会把 UTC 转成本地时间并丢失 Kind,使其与 DateTime.UtcNow 的比较偏移一个
// 时区(例如 UTC+8 用户的缓存过期判断会差 8 小时,导致缓存过久失效/供应陈旧数据)。
// 用 TryParse 以容忍损坏/遗留数据:单行时间戳解析失败时回退到 MinValue(对过期判断即视为已过期、
// 对时间排序即视为最旧,均为 fail-safe),避免一行坏数据抛异常中断整个读取路径。
private static DateTime _ParseDateTime(string value) =>
DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var result)
? result
: DateTime.MinValue;
private static CacheEntry _ReadEntry(SqliteDataReader r) =>
new()
{
CacheKey = r.GetString(0),
EntryType = (EntryType)r.GetInt32(1),
ContentType = r.GetString(2),
Data = r.IsDBNull(3) ? null : (byte[])r.GetValue(3),
DataSize = r.GetInt64(4),
FileHash = r.IsDBNull(5) ? null : r.GetString(5),
FilePath = r.IsDBNull(6) ? null : r.GetString(6),
ContentHash = r.IsDBNull(7) ? null : r.GetString(7),
ContentVersion = r.GetInt32(8),
CachedAt = _ParseDateTime(r.GetString(9)),
LastAccessAt = _ParseDateTime(r.GetString(10)),
ExpiresAt = r.IsDBNull(11) ? null : _ParseDateTime(r.GetString(11)),
HitCount = r.GetInt64(12),
Tags = r.GetString(13),
GroupName = r.GetString(14),
Priority = r.GetInt32(15),
};
private static void _BindInstanceParams(SqliteCommand cmd, InstanceCacheRow r)
{
cmd.Parameters.AddWithValue("@p", r.InstancePath);
cmd.Parameters.AddWithValue("@n", r.InstanceName);
cmd.Parameters.AddWithValue("@st", r.InstanceState);
cmd.Parameters.AddWithValue("@cty", r.CardType);
cmd.Parameters.AddWithValue("@is", r.IsStarred ? 1 : 0);
cmd.Parameters.AddWithValue("@lo", (object?)r.Logo ?? DBNull.Value);
cmd.Parameters.AddWithValue("@de", (object?)r.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@rt", (object?)r.ReleaseTime ?? DBNull.Value);
cmd.Parameters.AddWithValue("@vn", (object?)r.VanillaName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@vv", (object?)r.VanillaVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@dn", r.DropNumber);
cmd.Parameters.AddWithValue("@re", r.Reliable ? 1 : 0);
cmd.Parameters.AddWithValue("@lj", r.LoaderJson);
cmd.Parameters.AddWithValue("@mc", (object?)r.MainClass ?? DBNull.Value);
cmd.Parameters.AddWithValue("@ai", (object?)r.AssetsIndex ?? DBNull.Value);
cmd.Parameters.AddWithValue("@if", (object?)r.InheritsFrom ?? DBNull.Value);
cmd.Parameters.AddWithValue("@jv", (object?)r.JavaVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sh", r.SourceJsonHash);
cmd.Parameters.AddWithValue("@fv", r.FormatVersion);
}
private static InstanceCacheRow _ReadInstanceRow(SqliteDataReader r)
{
return new InstanceCacheRow
{
InstancePath = r.GetString(0),
InstanceName = r.GetString(1),
InstanceState = r.GetString(2),
CardType = r.GetInt32(3),
IsStarred = r.GetInt32(4) != 0,
Logo = r.IsDBNull(5) ? null : r.GetString(5),
Description = r.IsDBNull(6) ? null : r.GetString(6),
ReleaseTime = r.IsDBNull(7) ? null : r.GetString(7),
VanillaName = r.IsDBNull(8) ? null : r.GetString(8),
VanillaVersion = r.IsDBNull(9) ? null : r.GetString(9),
DropNumber = r.GetInt32(10),
Reliable = r.GetInt32(11) != 0,
LoaderJson = r.GetString(12),
MainClass = r.IsDBNull(13) ? null : r.GetString(13),
AssetsIndex = r.IsDBNull(14) ? null : r.GetString(14),
InheritsFrom = r.IsDBNull(15) ? null : r.GetString(15),
JavaVersion = r.IsDBNull(16) ? null : r.GetInt32(16),
SourceJsonHash = r.GetString(17),
FormatVersion = r.GetInt32(18),
CachedAt = _ParseDateTime(r.GetString(19)),
LastLoadedAt = r.IsDBNull(20) ? null : _ParseDateTime(r.GetString(20)),
};
}
private static void _BindComponentParams(SqliteCommand cmd, ComponentCacheRow r)
{
cmd.Parameters.AddWithValue("@instance_path", r.InstancePath);
cmd.Parameters.AddWithValue("@comp_type", r.CompType);
cmd.Parameters.AddWithValue("@file_name", r.FileName);
cmd.Parameters.AddWithValue("@relative_path", r.RelativePath);
cmd.Parameters.AddWithValue("@file_hash", (object?)r.FileHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("@file_size", r.FileSize);
cmd.Parameters.AddWithValue("@last_modified", r.LastModified.ToString("O"));
cmd.Parameters.AddWithValue("@enabled", r.Enabled ? 1 : 0);
cmd.Parameters.AddWithValue("@mod_name", (object?)r.ModName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_version", (object?)r.ModVersion ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_author", (object?)r.ModAuthor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_description", (object?)r.ModDescription ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_loader", (object?)r.ModLoader ?? DBNull.Value);
cmd.Parameters.AddWithValue("@mod_dependencies", (object?)r.ModDependencies ?? DBNull.Value);
cmd.Parameters.AddWithValue("@cache_version", r.CacheVersion);
cmd.Parameters.AddWithValue("@scan_hash", (object?)r.ScanHash ?? DBNull.Value);
}
private static ComponentCacheRow _ReadComponentRow(SqliteDataReader r)
{
return new ComponentCacheRow
{
InstancePath = r.GetString(0),
CompType = r.GetString(1),
FileName = r.GetString(2),
RelativePath = r.GetString(3),
FileHash = r.IsDBNull(4) ? null : r.GetString(4),
FileSize = r.GetInt64(5),
LastModified = _ParseDateTime(r.GetString(6)),
Enabled = r.GetInt32(7) != 0,
ModName = r.IsDBNull(8) ? null : r.GetString(8),
ModVersion = r.IsDBNull(9) ? null : r.GetString(9),
ModAuthor = r.IsDBNull(10) ? null : r.GetString(10),
ModDescription = r.IsDBNull(11) ? null : r.GetString(11),
ModLoader = r.IsDBNull(12) ? null : r.GetString(12),
ModDependencies = r.IsDBNull(13) ? null : r.GetString(13),
CacheVersion = r.GetInt32(14),
ScannedAt = _ParseDateTime(r.GetString(15)),
ScanHash = r.IsDBNull(16) ? null : r.GetString(16),
};
}
#endregion
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_writeLock.Dispose();
}
}
public record CacheStatsRow(long TotalEntries, long TotalSizeBytes, long ExpiredEntries, long InlineEntries, long FileEntries);
public record EvictionCandidate(string? CacheKey, EntryType EntryType, string? FileHash, long DataSize, int Priority, long HitCount);
@@ -0,0 +1,143 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using PCL.Core.Utils.Hash;
namespace PCL.Core.IO.Storage;
public class HashStorage(string folder, IHashProvider hashProvider, bool compressObjects = false, bool correctMisplacedFile = true, int prefixLength = 2)
{
/// <summary>
/// 保存文件到哈希存储库中
/// </summary>
/// <param name="fromPath">欲存储的文件位置</param>
/// <param name="hash">欲存储的文件的哈希,请确保与哈希存储库指定的哈希计算方法所用算法一致</param>
/// <returns>成功返回文件的哈希,失败返回 null</returns>
/// <exception cref="ArgumentNullException">提供的参数不正确</exception>
public async Task<string?> PutAsync(string fromPath, string? hash = null)
{
//参数检查
ArgumentNullException.ThrowIfNull(fromPath);
var filePath = Path.GetFullPath(fromPath);
if (!File.Exists(filePath)) return null;
//必要数据准备
await using var originalFs = File.Open(fromPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return await PutAsync(originalFs, hash).ConfigureAwait(false);
}
public async Task<string?> PutAsync(Stream input, string? hash = null)
{
ArgumentNullException.ThrowIfNull(input);
if (hash is not null && hash.Length != hashProvider.Length)
throw new ArgumentException("Provide hash is not correct", nameof(hash));
if (input.CanSeek) input.Position = 0;
var fileHash = hash ?? (await hashProvider.ComputeHashAsync(input).ConfigureAwait(false)).ToHexString();
var destPath = _GetDestPath(fileHash);
//纠正: 由于之前错误设计导致的文件访问效率低下的文件结构
if (correctMisplacedFile && _CorrectMisplacedFile(fileHash)) LogWrapper.Info("HashStorage", "Move misplaced file into correct folder");
//检查是否已存在保存的文件
if (File.Exists(destPath)) return fileHash;
await using var destinationFs = _GetSaveStream(destPath);
await input.CopyToAsync(destinationFs).ConfigureAwait(false);
return fileHash;
}
public Task<bool> DeleteAsync(string hash)
{
ArgumentNullException.ThrowIfNull(hash);
var filePath = _GetDestPath(hash);
if (!File.Exists(filePath) && correctMisplacedFile)
filePath = _GetMisplacedFilePath(hash);
if (!File.Exists(filePath)) return Task.FromResult(false);
try
{
File.Delete(filePath);
}
catch (FileNotFoundException) { /* 忽略此错误 */ }
catch (DirectoryNotFoundException ex)
{
LogWrapper.Error(ex, "HashStorage", $"Unexpected directory not found {filePath}");
return Task.FromResult(false);
}
catch (IOException ex)
{
LogWrapper.Error(ex, "HashStorage", $"Failed to delete file {filePath}");
return Task.FromResult(false);
}
catch (UnauthorizedAccessException ex)
{
LogWrapper.Error(ex, "HashStorage", $"Access denied when deleting file {filePath}");
return Task.FromResult(false);
}
return Task.FromResult(true);
}
public Stream? Get(string hash)
{
ArgumentNullException.ThrowIfNull(hash);
var destPath = _GetDestPath(hash);
if (correctMisplacedFile && _CorrectMisplacedFile(hash))
LogWrapper.Info("HashStorage", $"Move misplaced file into correct folder: {hash}");
return File.Exists(destPath) ? _GetReadStream(destPath) : null;
}
public bool Exists(string hash)
{
ArgumentNullException.ThrowIfNull(hash);
return File.Exists(_GetDestPath(hash)) || (correctMisplacedFile && File.Exists(_GetMisplacedFilePath(hash)));
}
private Stream _GetSaveStream(string destPath)
{
var fs = File.Open(destPath, FileMode.Create, FileAccess.ReadWrite, FileShare.Read);
if (compressObjects) return new DeflateStream(fs, CompressionMode.Compress);
return fs;
}
private Stream _GetReadStream(string destPath)
{
var fs = File.Open(destPath, FileMode.Open, FileAccess.Read, FileShare.Read);
if (compressObjects) return new DeflateStream(fs, CompressionMode.Decompress);
return fs;
}
private string _GetDestPath(string hash)
{
return Path.Combine(folder, _GetPrefixFolder(hash), hash);
}
private bool _CorrectMisplacedFile(string hash)
{
var misplacedPath = _GetMisplacedFilePath(hash);
if (!File.Exists(misplacedPath)) return false;
var correctPath = _GetDestPath(hash);
File.Move(misplacedPath, correctPath);
return true;
}
private string _GetMisplacedFilePath(string hash)
{
return Path.Combine(folder, hash);
}
private string _GetPrefixFolder(string hash)
{
if (hash.Length < prefixLength)
throw new ArgumentException($"Hash length({hash.Length}) is shorter than required prefix length({prefixLength})", nameof(hash));
var folderName = hash[..prefixLength];
var folderPath = Path.Combine(folder, folderName);
Directory.CreateDirectory(folderPath);
return folderName;
}
}