初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -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 <= 256KiB: Inline, > 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 ( < 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user