diff --git a/csharp/src/AsterCache.Service/LoadTiers.cs b/csharp/src/AsterCache.Service/LoadTiers.cs index c10635c..48ae00a 100644 --- a/csharp/src/AsterCache.Service/LoadTiers.cs +++ b/csharp/src/AsterCache.Service/LoadTiers.cs @@ -11,6 +11,11 @@ public static class LoadTiers { private static readonly ConcurrentDictionary Cache = new(StringComparer.OrdinalIgnoreCase); + private static int _generation; + + /// 캐시가 새 경계로 바뀔 때마다 늘어남 (캐시에서만 찾는 예산 추정의 재사용 키). + internal static int Generation => Volatile.Read(ref _generation); + public static LoadBoundary? For(GameSettings game) => For(game, default); // allowParse 가 거짓이면 이미 캐시에 있는 경계만 돌려줍니다 (없으면 null, 원시 트레이스를 파싱하지 않음). @@ -59,6 +64,7 @@ public static class LoadTiers return new FileInfo(p).Length; }, cancellationToken)); Cache[raw] = (written, length, b); + _ = Interlocked.Increment(ref _generation); return b; } catch (Exception e) when (e is IOException or UnauthorizedAccessException or PyValueException) diff --git a/csharp/src/AsterCache.Service/Prewarm.Games.cs b/csharp/src/AsterCache.Service/Prewarm.Games.cs index 0fa72a7..6876bac 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Games.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Games.cs @@ -195,6 +195,7 @@ public void SetGames(IReadOnlyList games) } } + _estimates.Retain(live); _rewarm.SetGames(games); _pins.Reconcile(games); bool budgetWaiting; diff --git a/csharp/src/AsterCache.Service/Prewarm.Memory.cs b/csharp/src/AsterCache.Service/Prewarm.Memory.cs index aa86ea3..13edf49 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Memory.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Memory.cs @@ -94,16 +94,31 @@ internal static IReadOnlyList OrderByValuePerByte(IReadOnlyList (G: g, V: 1.0 / (keep + n + 1) / (zoneMib(g) is > 0 and long z ? z : DefaultFullPrewarmMib))).OrderByDescending(x => x.V).Select(x => x.G)]; } - private long ZoneMib(GameSettings game) => ((_env.ZoneBytes(game) ?? 0) + (1 << 20) - 1) >> 20; + // estimate: 예산 계산의 추정으로만 쓰는 호출 (입력 파일·설정이 같고 MaxAge 안이면 이전 값 재사용). 읽을 범위·양을 정하는 호출은 false 로 매번 새로 읽음. + private long ZoneMib(GameSettings game, bool estimate = false) => + (((estimate ? EstimatedZoneBytes(game) : _env.ZoneBytes(game)) ?? 0) + (1 << 20) - 1) >> 20; - private long ObservedMib(GameSettings game, long totalMib, bool allowSourceRead = true) + private long? EstimatedZoneBytes(GameSettings game) => Estimated("zone", game, false, () => _env.ZoneBytes(game), true); + + // 추정의 재사용 키: 입력 파일 서명 + ZoneBytes·ObservedBytes 가 쓰는 게임 설정. 서명을 만들 수 없으면 (시험의 가짜 env 포함) null = 재사용 안 함. + private string? EstimateStampOf(GameSettings game, bool fromMemoryCaches) => + _env.EstimateStamp?.Invoke(game, fromMemoryCaches) is { } files + ? $"{files}|{game.Plan}|{game.Trace}|{game.OriginRoot}|{game.MaxStreamSet}|{game.MaxStreamMib}" + : null; + + private long? Estimated(string kind, GameSettings game, bool fromMemoryCaches, Func compute, bool estimate) => + _estimates.Get(kind, game.Name, estimate ? EstimateStampOf(game, fromMemoryCaches) : null, compute); + + private long ObservedMib(GameSettings game, long totalMib, bool allowSourceRead = true, bool estimate = false) { if (totalMib <= 0) { return totalMib; } - long? observedBytes = allowSourceRead ? _env.ObservedBytes(game) : _env.ObservedBytesIfCached(game); + long? observedBytes = allowSourceRead + ? Estimated("observed", game, false, () => _env.ObservedBytes(game), estimate) + : Estimated("observed-cached", game, true, () => _env.ObservedBytesIfCached(game), estimate); if (observedBytes is not { } bytes) { return totalMib; @@ -195,7 +210,7 @@ internal IReadOnlyList Warmed() var observedMib = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (GameSettings g in ordered) { - long total = ZoneMib(g); + long total = ZoneMib(g, estimate: true); bool hasZoneList = total > 0; long coldHeld; bool pending; @@ -219,7 +234,7 @@ internal IReadOnlyList Warmed() total = Math.Min(total, cap); long observed = unknownPendingSize ? 0 : total <= 0 || !hasZoneList ? total - : ObservedMib(g, total, allowSourceRead: !DriveHasRunningGame(g)); // 같은 디스크에서 게임이 실행 중이면 출처를 읽지 않고, 출처를 모르면 전부 관찰된 것으로 셈 + : ObservedMib(g, total, allowSourceRead: !DriveHasRunningGame(g), estimate: true); // 같은 디스크에서 게임이 실행 중이면 출처를 읽지 않고, 출처를 모르면 전부 관찰된 것으로 셈 totalMib[g.Name] = total; observedMib[g.Name] = observed; } diff --git a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs index 3ca71df..cd59bf4 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs @@ -470,8 +470,11 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate return false; } - long? zoneBytes = _env.ZoneBytes(game); - p = RaiseHotMib(game.Name, p, zoneBytes); // 기억한 목록 크기가 현재 목록보다 작으면 올림: 첫 관문·창 상한·표시가 현재 목록을 따름 + // 읽을지 기다릴지 정하는 판정까지는 예산 계산과 같은 추정 (읽지 않고 돌아가는 틱은 계획을 해석하지 않음). 추정으로는 지역 값만 고치고 지속되는 상태는 새로 읽은 값으로만 고침. + string? zoneStamp = EstimateStampOf(game, false); + long? zoneBytes = _estimates.Get("zone", game.Name, zoneStamp, () => _env.ZoneBytes(game)); + (long WarmedMib, long HotMib) remembered = p; + p = (p.WarmedMib, Math.Max(p.HotMib, ZoneListMib(zoneBytes))); // 기억한 목록 크기가 현재 목록보다 작으면 올림 (_partial 은 읽기 직전에) long avail = _env.AvailableRamMib(); long physCap = PhysCapMib(); long budgetCap = allocatedMib is { } allocated ? Math.Min(p.HotMib, allocated) : p.HotMib; @@ -490,6 +493,12 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate long plannedCapMib = partialCap ?? p.HotMib; string? inputStamp = PrewarmRun.InputStamp(game); long progressBefore = SupplementProgressBytes(game, p, inputStamp); + SupplementRetry? retryBefore; + lock (_gate) + { + _supplementRetry.TryGetValue(game.Name, out retryBefore); + } + if (SupplementDeferred(game, plannedCapMib, p.HotMib, progressBefore, inputStamp)) { return false; @@ -557,6 +566,29 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate return false; } + long? freshZone = _env.ZoneBytes(game); // 읽기로 정해진 뒤에는 계획을 새로 읽음 + if (ZoneListMib(freshZone) != ZoneListMib(zoneBytes)) + { + // 추정이 낡았음: 새 값을 추정에 넣고 이번 회는 읽지 않음 (재시도 기한 상태도 되돌림), 다음 보충이 새 값으로 판정 + _estimates.Set("zone", game.Name, zoneStamp, freshZone); + lock (_gate) + { + if (retryBefore is null) + { + _supplementRetry.Remove(game.Name); + } + else + { + _supplementRetry[game.Name] = retryBefore; + } + } + + return false; + } + + zoneBytes = freshZone; + p = RaiseHotMib(game.Name, remembered, zoneBytes); // 기억한 목록 크기가 현재 목록보다 작으면 올림: 창 상한·표시가 현재 목록을 따름 + using PrewarmRun run = PrewarmRun.Begin(); long? finalReadBytes = ReadSetBytes(warm, partial: windowMib is not null || skipBytes > 0); avail = _env.AvailableRamMib(); @@ -883,10 +915,12 @@ private void LogSupplementWaitsForMemory(string game, long fitMib, long warmedMi _log.Info($"{game}: supplement waits for memory (read {warmedMib} MiB, room for {fitMib} MiB)"); } + private static long ZoneListMib(long? zoneBytes) => zoneBytes is > 0 ? (zoneBytes.Value + (1 << 20) - 1) >> 20 : 0; + // 기억한 목록 크기 (HotMib) 를 현재 목록 (zoneBytes, MiB 올림) 이 더 크면 올려 돌려주고 _partial·_memoryPartial 도 같게 고침. 늘어날 때만: 줄이면 끝난 것으로 잘못 칠 수 있음. private (long WarmedMib, long HotMib) RaiseHotMib(string game, (long WarmedMib, long HotMib) p, long? zoneBytes) { - long listMib = zoneBytes is > 0 ? (zoneBytes.Value + (1 << 20) - 1) >> 20 : 0; + long listMib = ZoneListMib(zoneBytes); if (listMib <= p.HotMib) { return p; diff --git a/csharp/src/AsterCache.Service/Prewarm.cs b/csharp/src/AsterCache.Service/Prewarm.cs index ba5ed9b..015c49b 100644 --- a/csharp/src/AsterCache.Service/Prewarm.cs +++ b/csharp/src/AsterCache.Service/Prewarm.cs @@ -30,6 +30,7 @@ public sealed partial class Prewarmer private readonly ILog _log; private readonly string? _statusPath; private readonly Func _now; + private readonly WarmEstimateCache _estimates; // 예산 계산 (WarmBudget) 의 게임별 추정 재사용, 읽을 범위를 정하는 호출은 거치지 않음 private readonly object _gate = new(); // 짧은 메모리 상태만 보호: 스트림이 도는 동안에도 LastWarmed·Warmed 가 막히지 않게 private readonly object _run = new(); // 예열을 한 번에 하나로 private readonly Dictionary _activeWork = new(StringComparer.OrdinalIgnoreCase); // 설정 재적재 때 진행 중인 게임 상태를 보존, _gate 로 보호 @@ -123,6 +124,7 @@ public Prewarmer(IReadOnlyList games, IGameStreamer streamer, Prew _statusPath = statusPath; _now = now ?? (() => DateTimeOffset.UtcNow); _coordinator = coordinator; + _estimates = new WarmEstimateCache(_now); _pins = new PinKeeper(env, log, _now) { Coordinator = coordinator }; _solar = new Solar(env, log, _now); _rewarm = new Rewarmer(this, env, log, _now); diff --git a/csharp/src/AsterCache.Service/PrewarmEnv.cs b/csharp/src/AsterCache.Service/PrewarmEnv.cs index f76f459..cdbd97e 100644 --- a/csharp/src/AsterCache.Service/PrewarmEnv.cs +++ b/csharp/src/AsterCache.Service/PrewarmEnv.cs @@ -39,6 +39,10 @@ public sealed class PrewarmEnv /// 게임이 실행 중일 때는 이미 캐시에 있는 출처 정보만 사용합니다 (원시 트레이스 파일을 새로 읽지 않음). public Func ObservedBytesIfCached { get; init; } = _ => null; + /// ZoneBytes·ObservedBytes 가 읽는 입력 파일들의 서명 (두 번째 인자: 캐시에서만 출처를 찾는 ObservedBytesIfCached 용인가), 만들 수 없으면 null. + /// 예산 계산 (WarmBudget) 의 추정 재사용 키입니다. 시험 기본값은 null (재사용 안 함). + public Func? EstimateStamp { get; init; } + public required Func TraceExists { get; init; } /// 계획을 만들 때의 유효 존 상한 (바이트), 모르면 null. 틈 메우기 예산의 상한이며 현재 존 크기와 무관합니다. @@ -291,6 +295,7 @@ public static PrewarmEnv Create(OrbitCalibration? orbitCalibration = null, Orbit ZoneBytes = g => PrefetchStreamer.WarmBytes(g) is { } z ? Math.Min(z, g.StreamCapBytes(z)) : null, ObservedBytes = PrefetchStreamer.ObservedBytes, ObservedBytesIfCached = g => PrefetchStreamer.ObservedBytes(g, allowSourceRead: false), + EstimateStamp = PrefetchStreamer.EstimateStamp, TraceExists = g => File.Exists(g.Trace), EffectiveZoneBytes = EffectiveZoneFromPlan, }; diff --git a/csharp/src/AsterCache.Service/Streaming.cs b/csharp/src/AsterCache.Service/Streaming.cs index 88239c9..f096305 100644 --- a/csharp/src/AsterCache.Service/Streaming.cs +++ b/csharp/src/AsterCache.Service/Streaming.cs @@ -98,6 +98,7 @@ private sealed record TraceSourceCacheEntry(long Length, long LastWriteTicks, IR /// 시험용: 원시 트레이스를 실제로 파싱한 횟수 (경로별). internal static readonly ConcurrentDictionary SourceTraceParses = new(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary TraceSourceCache = new(StringComparer.OrdinalIgnoreCase); + private static int _sourceCacheGeneration; // TraceSourceCache 가 바뀔 때마다 늘림 (캐시에서만 찾는 예산 추정의 재사용 키) internal static void ForgetRemovedGames(IEnumerable games) { @@ -110,6 +111,8 @@ internal static void ForgetRemovedGames(IEnumerable games) { _ = TraceSourceCache.Remove(path + "|" + game.OriginRoot); } + + _ = Interlocked.Increment(ref _sourceCacheGeneration); } foreach (string path in paths) @@ -242,6 +245,22 @@ private static long HeadBytesOf(GameSettings game, CancellationToken cancellatio } } + // 예산 추정 (WarmBytes·ObservedBytes) 이 읽는 입력의 (존재, 길이, 수정 시각): 목록의 입력 (PrewarmRun.InputStamp) 에 원시·boot 트레이스를 더함. 메타데이터 조회뿐이며 만들 수 없으면 null. + // ZoneBytes·LoadRows·LoadSourceTraceRows·LoadTiers.For 의 입력이 바뀌면 여기도 고칠 것. + // fromMemoryCaches: 출처를 메모리 캐시에서만 찾는 추정은 그 캐시의 세대도 입력이라 서명에 더함. + internal static string? EstimateStamp(GameSettings game, bool fromMemoryCaches) + { + string? files = PrewarmRun.InputStamp(game); + string? traces = PrewarmRun.Stamp(() => [AutoTracer.RawTracePath(game), AutoTracer.BootTracePath(game)]); + if (files is null || traces is null) + { + return null; + } + + string stamp = $"{files}|{traces}"; + return fromMemoryCaches ? $"{stamp}|{Volatile.Read(ref _sourceCacheGeneration)}:{LoadTiers.Generation}" : stamp; + } + /// 접근 순서 앞쪽 HeadBytesOf 만큼만 읽습니다 (스케줄러가 앞에 둔 부분과 같음). public void StreamHead(GameSettings game, CancellationToken stop) => StreamHead(game, stop, null); @@ -741,6 +760,7 @@ private static List LoadRows(GameSettings game, bool withWarmOnly, o lock (TraceSourceCacheLock) { TraceSourceCache[key] = new TraceSourceCacheEntry(length, written, rows); + _ = Interlocked.Increment(ref _sourceCacheGeneration); } return rows; @@ -1017,11 +1037,14 @@ internal static void NoteCalibrationCapped() } // 목록의 입력 (계획·트레이스·예열 전용 옆 파일·가변 표지) 의 (존재, 길이, 수정 시각). 메타데이터 조회뿐이며 읽지 못하면 null (재사용 안 함). - internal static string? InputStamp(GameSettings game) + internal static string? InputStamp(GameSettings game) => + Stamp(() => [game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game)]); + + internal static string? Stamp(Func paths) { try { - return string.Join('|', new[] { game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game) }.Select(path => + return string.Join('|', paths().Select(path => { var info = new FileInfo(path); return info.Exists ? $"{info.Length}:{info.LastWriteTimeUtc.Ticks}" : "-"; diff --git a/csharp/src/AsterCache.Service/WarmEstimateCache.cs b/csharp/src/AsterCache.Service/WarmEstimateCache.cs new file mode 100644 index 0000000..51375a9 --- /dev/null +++ b/csharp/src/AsterCache.Service/WarmEstimateCache.cs @@ -0,0 +1,79 @@ +namespace AsterCache.Service; + +/// 예산 계산 (WarmBudget) 이 쓰는 게임별 추정값 (전체 크기, 관찰량) 을 입력 서명이 같고 MaxAge 안이면 다시 계산하지 않고 돌려줍니다. +/// 값 (모름 = null 포함) 과 서명만 보관합니다: 계획·목록 해석 결과는 붙잡지 않음. 예열이 실제로 읽을 범위를 정하는 호출은 이 캐시를 거치지 않습니다. +internal sealed class WarmEstimateCache(Func now) +{ + // 입력 파일이 그대로여도 게임 파일 크기 (게임 갱신) 는 서명에 없어서, 이 시간 안에 반영되게 합니다. + internal static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(10); + + private sealed record Entry(string Stamp, DateTimeOffset At, long? Value); + + private readonly object _lock = new(); + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + + /// stamp 가 null 이면 (서명을 만들 수 없음) 저장하지 않고 compute 를 그대로 부릅니다. compute 는 잠금 밖에서 돕니다. + public long? Get(string kind, string game, string? stamp, Func compute) + { + if (stamp is null) + { + return compute(); + } + + string key = kind + "|" + game; + DateTimeOffset at = now(); + lock (_lock) + { + if (_entries.TryGetValue(key, out Entry? hit) && hit.Stamp == stamp && at >= hit.At && at - hit.At < MaxAge) + { + return hit.Value; + } + } + + long? value = compute(); + lock (_lock) + { + _entries[key] = new Entry(stamp, at, value); + } + + return value; + } + + /// 새로 읽은 값을 넣습니다. stamp 는 읽기 전에 잡은 것 (읽는 동안 입력이 바뀌면 다음에 다시 계산됨), null 이면 넣지 않음. + public void Set(string kind, string game, string? stamp, long? value) + { + if (stamp is null) + { + return; + } + + DateTimeOffset at = now(); + lock (_lock) + { + _entries[kind + "|" + game] = new Entry(stamp, at, value); + } + } + + /// 목록에 없는 게임의 항목을 지웁니다 (games 는 대소문자 무시 집합) (항목 수가 등록 게임 수를 넘지 않게). + public void Retain(IReadOnlySet games) + { + lock (_lock) + { + foreach (string key in _entries.Keys.Where(k => !games.Contains(k[(k.IndexOf('|') + 1)..])).ToList()) + { + _ = _entries.Remove(key); + } + } + } + + internal int Count + { + get + { + lock (_lock) + { + return _entries.Count; + } + } + } +} diff --git a/csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs b/csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs new file mode 100644 index 0000000..912e417 --- /dev/null +++ b/csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs @@ -0,0 +1,393 @@ +using AsterCache.Core.Plan; +using AsterCache.Core.Settings; +using AsterCache.Core.Trace; +using AsterCache.Prefetch; +using AsterCache.Service; +using AsterCache.Windows; +using Xunit; + +namespace AsterCache.Tests.Service; + +/// 예산 계산 (WarmBudget) 이 같은 입력의 게임별 추정 (전체 크기, 관찰량) 을 다시 읽지 않고, 입력·설정·시간이 바뀌면 다시 계산하는가. +public sealed class WarmEstimateCacheTests +{ + private const long Mib = 1L << 20; + private readonly ListLog _log = new(); + private DateTimeOffset _now = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero); + private string _stamp = "v1"; + private int _memoryGeneration; + private long? _zoneMib = 100; + private int _zoneCalls; + private int _observedCalls; + private int _cachedCalls; + private bool _running; + private long _avail = 6000; + private readonly Streamer _streamer = new(); + + private sealed class Streamer : IGameStreamer + { + public int Calls { get; private set; } + + public long ReadMib { get; set; } = 100; + + public long? TotalMib { get; set; } + + public Action>? OnProgress { get; set; } + + public StreamStats Stream(GameSettings game) => throw new NotSupportedException(); + + public (StreamStats Stats, bool Complete) StreamPrewarm(GameSettings game, CancellationToken stop, Action? progress, long maxBytes) + { + Calls++; + progress?.Invoke(0, (TotalMib ?? ReadMib) * Mib); + if (OnProgress is not null && progress is not null) + { + OnProgress(progress); + } + + return (new StreamStats(ReadMib * Mib, 1, 0, 0.01), !stop.IsCancellationRequested); + } + + public (StreamStats Stats, bool Complete) Stream(GameSettings game, CancellationToken stop, Action? progress) => + StreamPrewarm(game, stop, progress, 0); + } + + private static GameSettings Game(string name) => Games.Make(name, name + ".exe") with { OriginRoot = @"D:\Games\" + name }; + + private Prewarmer Make(params GameSettings[] games) => new(games, _streamer, new PrewarmEnv + { + AvailableRamMib = () => _avail, + GameRunning = _ => _running, + PhysicalRamBytes = () => 16L << 30, + ZoneBytes = _ => + { + _zoneCalls++; + return _zoneMib is { } z ? z * Mib : null; + }, + ObservedBytes = _ => + { + _observedCalls++; + return 40 * Mib; + }, + ObservedBytesIfCached = _ => + { + _cachedCalls++; + return 30 * Mib; + }, + EstimateStamp = (_, memoryCaches) => memoryCaches ? $"{_stamp}:{_memoryGeneration}" : _stamp, + TraceExists = _ => true, + ProbeReadMs = _ => new ProbeResult(8, 8, 5.0), + }, _log, null, () => _now); + + private Prewarmer Ranked(params GameSettings[] games) + { + Prewarmer p = Make(games); + p.WarmRank = list => list; + Assert.True(p.Run(games[0], "service start").Ran); + return p; + } + + [Fact] + public void ASecondBudgetOnTheSameInputsDoesNotRecomputeTheEstimates() + { + Prewarmer p = Ranked(Game("a")); + var first = p.WarmBudget()!.Value; + (int zone, int observed) = (_zoneCalls, _observedCalls); + + var second = p.WarmBudget()!.Value; + + Assert.Equal((zone, observed), (_zoneCalls, _observedCalls)); + Assert.Equal((first.UsedMib, first.AllocMib["a"]), (second.UsedMib, second.AllocMib["a"])); + Assert.Equal(100, second.AllocMib["a"]); // 관찰 40 + 보충 60 + } + + [Fact] + public void AChangedInputSignatureRecomputesTheEstimates() + { + Prewarmer p = Ranked(Game("a")); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _stamp = "v2"; // 입력 파일의 길이·수정 시각이 달라짐 + _zoneMib = 200; + var budget = p.WarmBudget()!.Value; + + Assert.True(_zoneCalls > zone); + Assert.Equal(200, budget.AllocMib["a"]); + } + + [Fact] + public void EstimatesExpireAfterTheMaxAge() + { + Prewarmer p = Ranked(Game("a")); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _now += WarmEstimateCache.MaxAge - TimeSpan.FromSeconds(1); + _ = p.WarmBudget(); + Assert.Equal(zone, _zoneCalls); + + _now += TimeSpan.FromSeconds(2); + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + } + + [Fact] + public void AChangedGameSettingRecomputesTheEstimates() + { + GameSettings a = Game("a"); + Prewarmer p = Ranked(a); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + p.SetGames([a with { MaxStreamMib = 64, MaxStreamSet = true }]); + _ = p.WarmBudget(); + + Assert.True(_zoneCalls > zone); + } + + [Fact] + public void AnUnknownEstimateIsReusedUntilTheInputAppears() + { + Prewarmer p = Ranked(Game("a")); + (_stamp, _zoneMib) = ("v2", null); // 계획이 사라짐 + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _ = p.WarmBudget(); + Assert.Equal(zone, _zoneCalls); + + (_stamp, _zoneMib) = ("v3", 100); // 계획 파일이 생김 + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + } + + [Fact] + public void RunReadsTheListFreshEvenWhenTheBudgetEstimateIsCached() + { + GameSettings a = Game("a"); + Prewarmer p = Ranked(a); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _ = p.Run(a, "service start"); // 읽을 범위를 정하는 호출은 서명이 같아도 새로 읽음 + + Assert.True(_zoneCalls > zone); + } + + // 메모리 한도로 앞부분 (windowMib 중 readMib) 만 읽고 멈춘 부분 예열 (목록 hotMib, 시작 가용 RAM startAvail). + private Prewarmer Partial(GameSettings g, long hotMib, long startAvail, long windowMib, long readMib) + { + (_zoneMib, _avail) = (hotMib, startAvail); + (_streamer.ReadMib, _streamer.TotalMib) = (readMib, windowMib); + Prewarmer p = Make(g); + p.WarmRank = list => list; + _streamer.OnProgress = progress => + { + _avail = 4096; + progress(readMib * Mib, windowMib * Mib); + }; + PrewarmResult r = p.Run(g, "test"); + Assert.True(r.Ran, r.SkipReason + " | " + string.Join("; ", _log.Lines)); + _streamer.OnProgress = null; + (_streamer.ReadMib, _streamer.TotalMib) = (100, null); + _now += TimeSpan.FromMinutes(3); + return p; + } + + [Fact] + public void ASupplementThatOnlyWaitsForMemoryDoesNotParseTheListAgain() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 8000, 6000, 1648, 300); + _avail = 4096 + Prewarmer.FinalShrinkHeadroomMib + 300 + 255; // 들어가는 범위가 읽은 양보다 255 MiB 만 큼: 읽지 않고 기다림 + int reads = _streamer.Calls; + + Assert.False(p.Supplement(a, CancellationToken.None)); + int zone = _zoneCalls; + Assert.False(p.Supplement(a, CancellationToken.None)); + Assert.False(p.Supplement(a, CancellationToken.None)); + + Assert.Equal(zone, _zoneCalls); // 두 번째부터 계획 해석 0 + Assert.Equal(reads, _streamer.Calls); + Assert.Single(_log.Lines, l => l.Contains("a: supplement waits for memory (read 300 MiB, room for 555 MiB)", StringComparison.Ordinal)); + } + + [Fact] + public void ASupplementThatReadsFetchesTheListFreshRightBeforeReading() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 8000, 6000, 1648, 300); + _avail = 20000; + _ = p.WarmBudget(); // 추정이 캐시에 있음 + (int zone, int reads) = (_zoneCalls, _streamer.Calls); + + Assert.True(p.Supplement(a, CancellationToken.None)); + + Assert.True(_zoneCalls > zone); // 읽기 직전에 새로 읽음 + Assert.Equal(reads + 1, _streamer.Calls); + } + + [Fact] + public void WhenTheFreshListDiffersFromTheEstimateThisRoundDoesNotReadAndTheNextOneJudgesWithTheFreshValue() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 1000, 5000, 648, 500); + _avail = 5100; + _ = p.WarmBudget(); // 추정: 목록 1000 MiB + _zoneMib = 3000; // 게임 파일이 커져 목록이 늘었지만 입력 파일의 서명은 같음 + (int reads, var before, int logs) = (_streamer.Calls, p.PartialInfo("a"), _log.Lines.Count); + + // 추정으로는 통째로 들어가 읽을 판이지만 읽기 직전의 새 값 (3000 MiB) 이 달라 이번 회는 읽지 않고, 상태도 로그도 바꾸지 않음 + Assert.False(p.Supplement(a, CancellationToken.None)); + Assert.Equal((reads, before, logs), (_streamer.Calls, p.PartialInfo("a"), _log.Lines.Count)); + + // 다음 보충은 새 값으로 판정: 들어가는 범위 748 MiB 가 읽은 500 MiB 와 256 미만 차이라 기다림 + int zone = _zoneCalls; + Assert.False(p.Supplement(a, CancellationToken.None)); + Assert.Equal(zone, _zoneCalls); + Assert.Equal(reads, _streamer.Calls); + Assert.Equal(1000, p.PartialInfo("a")!.Value.HotMib); // 기다리기만 한 회는 기억한 목록 크기를 올리지 않음 (상태는 읽기 직전에 새로 읽은 값으로만 고침) + Assert.Single(_log.Lines, l => l.Contains("a: supplement waits for memory (read 500 MiB, room for 748 MiB)", StringComparison.Ordinal)); + } + + [Fact] + public void AStaleLargerEstimateNeverRaisesTheRememberedListSizeAndTheNextRoundReads() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 1000, 5000, 648, 500); + _avail = 20000; + _zoneMib = 3000; + _ = p.WarmBudget(); // 낡은 추정: 3000 MiB + _zoneMib = 1000; // 게임 갱신으로 목록의 파일이 줄었지만 입력 파일의 서명은 같음 + int reads = _streamer.Calls; + + Assert.False(p.Supplement(a, CancellationToken.None)); // 새 값이 다르므로 이번 회는 읽지 않음 + Assert.Equal(reads, _streamer.Calls); + Assert.Equal(1000, p.PartialInfo("a")!.Value.HotMib); // 추정값 3000 으로 올라가지 않음 + + Assert.True(p.Supplement(a, CancellationToken.None)); // 다음 회는 새 값으로 판정해 읽음 + Assert.Equal(reads + 1, _streamer.Calls); + } + + [Fact] + public void WhileAGameRunsOnTheDiskOnlyTheCachedSourceEstimateIsReusedAndNoSourceIsRead() + { + Prewarmer p = Ranked(Game("a")); + _running = true; + _ = p.WarmBudget(); + (int observed, int cached) = (_observedCalls, _cachedCalls); + Assert.True(cached > 0); + + _ = p.WarmBudget(); + Assert.Equal((observed, cached), (_observedCalls, _cachedCalls)); + + _memoryGeneration++; // 출처 캐시가 채워짐: 캐시에서만 찾는 추정은 새로 계산하되 원본은 여전히 읽지 않음 + _ = p.WarmBudget(); + Assert.True(_cachedCalls > cached); + Assert.Equal(observed, _observedCalls); + } + + [Fact] + public void CacheKeepsOnlyRetainedGamesAndRefreshesAfterTheMaxAge() + { + var cache = new WarmEstimateCache(() => _now); + int calls = 0; + long? Compute() => ++calls; + + Assert.Equal(1, cache.Get("zone", "a", "s", Compute)); + Assert.Equal(1, cache.Get("zone", "a", "s", Compute)); + Assert.Equal(2, cache.Get("zone", "a", "t", Compute)); // 서명이 달라짐 + Assert.Equal(3, cache.Get("zone", "a", null, Compute)); // 서명을 못 만들면 저장하지 않음 + Assert.Equal(4, cache.Get("zone", "a", null, Compute)); + _ = cache.Get("zone", "b", "s", Compute); + _ = cache.Get("observed", "b", "s", Compute); + Assert.Equal(3, cache.Count); + + cache.Retain(new HashSet(["A"], StringComparer.OrdinalIgnoreCase)); + Assert.Equal(1, cache.Count); + + _now += WarmEstimateCache.MaxAge; + Assert.Equal(7, cache.Get("zone", "a", "t", Compute)); + } + + [Fact] + public void TheInputStampChangesForEachInputFileAndTheMemoryCacheGenerationAndNotOtherwise() + { + string dir = Path.Combine(Path.GetTempPath(), "aster_estimate_stamp_" + Guid.NewGuid().ToString("N")); + string root = Path.Combine(dir, "game"); + _ = Directory.CreateDirectory(root); + try + { + string asset = Path.Combine(root, "asset.bin"); + File.WriteAllBytes(asset, new byte[8 * (int)Mib]); + GameSettings game = Game("stamp") with + { + OriginRoot = root, + AllowedRoots = [root], + Plan = Path.Combine(dir, "stamp.plan.json"), + Trace = Path.Combine(dir, "stamp.prefetch.json"), + }; + string?[] files = + [ + game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game), AutoTracer.RawTracePath(game), AutoTracer.BootTracePath(game), + ]; + string? last = PrefetchStreamer.EstimateStamp(game, false); + Assert.Equal(last, PrefetchStreamer.EstimateStamp(game, false)); + foreach (string? file in files) + { + File.WriteAllText(file!, "{}"); // 생성 + string? created = PrefetchStreamer.EstimateStamp(game, false); + Assert.NotEqual(last, created); + + File.WriteAllText(file!, "{ }"); // 길이 변경 + string? resized = PrefetchStreamer.EstimateStamp(game, false); + Assert.NotEqual(created, resized); + + File.SetLastWriteTimeUtc(file!, DateTime.UtcNow.AddHours(-1)); // 수정 시각 변경 + last = PrefetchStreamer.EstimateStamp(game, false); + Assert.NotEqual(resized, last); + } + + Assert.NotEqual(last, PrefetchStreamer.EstimateStamp(game, true)); // 캐시 세대가 더해짐 + + foreach (string? file in files.Where(f => f != game.Trace && f != AutoTracer.RawTracePath(game))) + { + File.Delete(file!); // 아래 파싱에는 목록과 원시 트레이스만 필요 + } + + PlanOutput.SavePrefetch(game.Trace, [new TraceEvent(asset, 0, 8 * Mib)]); + PlanOutput.SavePrefetch(AutoTracer.RawTracePath(game), [new TraceEvent(asset, 6 * Mib, 2 * Mib)]); + string? beforeParse = PrefetchStreamer.EstimateStamp(game, true); + string? beforeParseFiles = PrefetchStreamer.EstimateStamp(game, false); + _ = PrefetchStreamer.ObservedBytes(game); // 원시 트레이스를 파싱해 출처 캐시를 채움 + Assert.Equal(beforeParseFiles, PrefetchStreamer.EstimateStamp(game, false)); + string? afterParse = PrefetchStreamer.EstimateStamp(game, true); + Assert.NotEqual(beforeParse, afterParse); // 캐시가 채워지면 캐시에서만 찾는 추정의 서명이 달라짐 + PrefetchStreamer.ForgetRemovedGames([game]); + Assert.NotEqual(afterParse, PrefetchStreamer.EstimateStamp(game, true)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ChangingTheOriginRootOrTheStreamLimitModeRecomputesTheEstimates() + { + GameSettings a = Game("a"); + Prewarmer p = Ranked(a); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + p.SetGames([a with { OriginRoot = @"D:\Other" }]); + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + + zone = _zoneCalls; + p.SetGames([a with { OriginRoot = @"D:\Other", MaxStreamSet = true }]); + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + } +}