diff --git a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs index 1ac054b..50cfc45 100644 --- a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs +++ b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs @@ -7,7 +7,7 @@ namespace AsterCache.Service; /// Rewarmer 의 사다리 (Solar) 유지: 30초 틱마다 시점이 된 게임의 캐시를 디스크 읽기 없이 새로 고치고, 서비스를 다시 시작한 뒤 표식이 없는 게임은 재예열 점검이 warm 으로 판정할 때 첫 사다리를 만듭니다. /// 게이트는 재예열 점검과 같은 함수 (ReadBlock) 를 씁니다: 재배치 중이 아니고, 같은 디스크의 게임이 돌지 않고, 게임 종료 직후 유예가 지났고, 예산 안의 게임이고, 디스크가 바쁘지 않을 때만 만집니다. -/// 여기서는 재예열을 직접 부르지 않습니다: 디스크에서 다시 읽을지는 재예열 점검 (rewarm_minutes 주기, 표본으로 cold 확인, 자체 게이트) 만 정하므로 HDD 를 다시 읽는 빈도는 늘지 않습니다. +/// 여기서는 재예열을 직접 부르지 않습니다: 디스크에서 다시 읽을지는 재예열 점검 (rewarm_minutes 주기, 표본으로 cold 확인 또는 사다리가 남긴 다시 채움 표시, 자체 게이트) 만 정하므로 HDD 를 다시 읽는 빈도는 늘지 않습니다. /// 사다리를 새로 고치는 일 자체 (메모리 안에서만 만지기) 는 자동 모드·rewarm_minutes 와 무관하게 memory.standby_ladder 로만 켜고 끕니다. public sealed partial class Rewarmer { @@ -71,7 +71,7 @@ private void Refresh(GameSettings g, CancellationToken ct) private void LadderUnmarked(GameSettings g, CancellationToken ct) { Solar solar = _prewarm.Solar; - if (solar.Enabled && !solar.HasMark(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.LadderReads(_prewarm.CoveredPart(g)) is { Count: > 0 } reads) + if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.LadderReads(_prewarm.CoveredPart(g)) is { Count: > 0 } reads) { Run(g, ct, token => solar.Order(g, reads, token), "cache ladder"); } diff --git a/csharp/src/AsterCache.Service/Rewarm.cs b/csharp/src/AsterCache.Service/Rewarm.cs index 961586e..ae2a405 100644 --- a/csharp/src/AsterCache.Service/Rewarm.cs +++ b/csharp/src/AsterCache.Service/Rewarm.cs @@ -8,7 +8,7 @@ namespace AsterCache.Service; -/// 예열 유지: rewarm_minutes 마다, 예열한 게임 자신이 안 돌고 같은 디스크의 다른 등록 게임이 안 돌거나 돌아도 한가하고 (Prewarmer.BusyOthers) 그 게임 드라이브 처리량이 자동 재배치의 유휴 기준 아래일 때 (CPU 부하는 보지 않음), 예열한 적 있는 게임의 핫 집합 몇 곳을 읽어 보고 식었으면 (CacheProbe.Cold: 표본 8 곳 중 2 곳 이상이 CacheProbe.ColdThresholdMs 초과) 다시 예열합니다. +/// 예열 유지: rewarm_minutes 마다, 예열한 게임 자신이 안 돌고 같은 디스크의 다른 등록 게임이 안 돌거나 돌아도 한가하고 (Prewarmer.BusyOthers) 그 게임 드라이브 처리량이 자동 재배치의 유휴 기준 아래일 때 (CPU 부하는 보지 않음), 예열한 적 있는 게임의 핫 집합 몇 곳을 읽어 보고 식었으면 (CacheProbe.Cold: 표본 8 곳 중 2 곳 이상이 CacheProbe.ColdThresholdMs 초과) 다시 예열합니다. 사다리 (Solar) 가 목록의 일부가 메모리에 없는 것을 찾은 게임은 표본이 warm 이어도 다시 예열합니다 (게임마다 마지막 예열 뒤 rewarm_minutes 가 지난 뒤에만). public sealed partial class Rewarmer { private readonly Prewarmer _prewarm; @@ -20,6 +20,7 @@ public sealed partial class Rewarmer private DateTimeOffset? _next; private readonly object _checkLock = new(); private readonly Dictionary _checkState = new(StringComparer.OrdinalIgnoreCase); // 게임별 마지막 재확인 결과 (로그를 바뀔 때만 남김) + private readonly Dictionary _missingTriedAt = new(StringComparer.OrdinalIgnoreCase); // 게임별로 사다리의 다시 채움 표시로 재예열을 마지막 시도한 시각 (성공·실패·건너뜀 모두), _checkLock 으로 보호, 메모리에만 둠 public Rewarmer(Prewarmer prewarm, PrewarmEnv env, ILog log, Func now) { @@ -40,6 +41,11 @@ public void SetGames(IReadOnlyList games) { _checkState.Remove(gone); } + + foreach (string gone in _missingTriedAt.Keys.Where(k => !live.Contains(k)).ToList()) + { + _missingTriedAt.Remove(gone); + } } _prewarm.Solar.Prune(live); @@ -129,7 +135,7 @@ public void Tick(AutoSettings auto, CancellationToken ct = default) continue; } - Check(g, ct, RoomOf(g, budget)); + Check(g, ct, RoomOf(g, budget), auto, now); } if (nextGateDue is { } due) @@ -223,7 +229,7 @@ private string Blockers(params GameSettings[] games) } } - private void Check(GameSettings g, CancellationToken ct, long? roomMib) + private void Check(GameSettings g, CancellationToken ct, long? roomMib, AutoSettings auto, DateTimeOffset now) { if (_prewarm.RewarmAlreadyCovered(g)) { @@ -231,6 +237,44 @@ private void Check(GameSettings g, CancellationToken ct, long? roomMib) return; } + // 사다리가 목록의 일부가 메모리에 없는 것을 찾았으면 표본이 warm 이어도 식은 것으로 봅니다 (표본은 8 곳뿐). 보충 대기 게임은 보충이 사다리를 다시 만들고, 트리거가 기다리는 동안은 표시를 둔 채 미룸. + Solar solar = _prewarm.Solar; + if (solar.HasMissing(g.Name) && !_prewarm.SupplementPending(g.Name) && !_prewarm.TriggerWaitPending()) + { + // 점검이 당겨져도 (게임 종료·유예) 표시로 인한 재예열은 게임마다 마지막 예열과 마지막 시도 중 늦은 쪽 뒤 rewarm_minutes 가 지나야 함: + // 재예열 뒤 사다리가 곧 표시를 다시 세우거나 예열이 실패·건너뛰어도 HDD 를 주기보다 잦게 읽지 않음 + DateTimeOffset? last = _prewarm.LastWarmed(g.Name); + lock (_checkLock) + { + if (_missingTriedAt.TryGetValue(g.Name, out DateTimeOffset tried) && (last is not { } l || tried > l)) + { + last = tried; + } + } + + if (last is { } since && now >= since && now - since < TimeSpan.FromMinutes(auto.RewarmMinutes)) + { + NoteCheck(g, "ladder-missing-wait", $"{g.Name}: rewarm check: part of the list is not in memory (found by the ladder), waiting until {auto.RewarmMinutes} min after the last prewarm attempt"); + return; + } + + _ = solar.TakeMissing(g.Name); + lock (_checkLock) + { + _checkState[g.Name] = "ladder-missing"; // 로그는 매번 남기고, 채운 뒤 다음 점검의 warm 도 새 상태로 기록되게 함 + _missingTriedAt[g.Name] = now; + } + + _log.Info($"{g.Name}: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)"); + PrewarmResult result = _prewarm.RewarmGame(g, ct, roomMib); + if (!result.Ran && result.Stats is not { Bytes: > 0 }) + { + solar.RestoreMissing(g.Name); // 읽기 전에 건너뜀 (메모리·지연·대여 거절·재배치 등): 다음 점검에서 다시 시도 + } + + return; + } + ProbeResult? probe = Probe(g, ct, out bool failed); if (failed) { diff --git a/csharp/src/AsterCache.Service/Solar.cs b/csharp/src/AsterCache.Service/Solar.cs index 9a3a8aa..6da7beb 100644 --- a/csharp/src/AsterCache.Service/Solar.cs +++ b/csharp/src/AsterCache.Service/Solar.cs @@ -13,6 +13,7 @@ internal sealed class Solar(PrewarmEnv env, ILog log, Func now) { private readonly object _lock = new(); private readonly Dictionary _marks = new(StringComparer.OrdinalIgnoreCase); // 마지막으로 만든 사다리의 표식, 메모리에만 둠 (서비스를 다시 시작하면 없음), _lock 으로 보호 + private readonly HashSet _missing = new(StringComparer.OrdinalIgnoreCase); // 사다리가 목록의 일부가 캐시에 없음을 발견한 게임 (재예열 점검이 다시 채울 때까지), 메모리에만 둠, _lock 으로 보호 public bool Enabled => env.StandbyLadderEnabled(); @@ -24,7 +25,25 @@ public bool HasMark(string game) } } - /// 설정에서 빠진 게임의 표식을 버립니다. + /// 사다리가 목록의 일부가 캐시에 없는 것을 발견해 다시 채워야 하는 게임인가. + public bool HasMissing(string game) + { + lock (_lock) + { + return _missing.Contains(game); + } + } + + /// 다시 채워야 한다는 표시를 지우고 있었는지 돌려줍니다 (재예열을 시도하는 시점에 부름: 읽기 전에 건너뛰면 호출한 쪽이 RestoreMissing 으로 되돌리고, 빈도는 호출한 쪽의 대기 조건이 정함). + public bool TakeMissing(string game) + { + lock (_lock) + { + return _missing.Remove(game); + } + } + + /// 설정에서 빠진 게임의 표식과 다시 채움 표시를 버립니다. public void Prune(IReadOnlySet live) { lock (_lock) @@ -33,6 +52,8 @@ public void Prune(IReadOnlySet live) { _ = _marks.Remove(gone); } + + _ = _missing.RemoveWhere(k => !live.Contains(k)); } } @@ -62,8 +83,13 @@ public void Prune(IReadOnlySet live) log.Info($"{game.Name}: ladder stopped after {r.Bytes >> 20} MiB{Errors(r)}"); break; default: - Forget(game.Name); - log.Info($"{game.Name}: ladder needs disk reads ({r.SlowPieces} slow pieces), skipped{Errors(r)}"); + if (!NoteIncomplete(game.Name, r)) + { + log.Info($"{game.Name}: ladder was too slow without disk reads seen ({r.SlowPieces} slow pieces{Errors(r)}), skipped"); + break; + } + + log.Info($"{game.Name}: ladder found data missing from memory {Missing(r)}, left to the rewarm check"); break; } @@ -71,7 +97,7 @@ public void Prune(IReadOnlySet live) } /// 표식이 있는 게임의 사다리를 다시 만듭니다 (디스크 읽기 없음). 표식이 없거나 꺼져 있으면 null. - /// 디스크 읽기가 섞이면 표식을 지우고 기존 재예열 점검에 맡깁니다: 재예열은 여기서 부르지 않으며, 점검이 warm 으로 판정하거나 재예열이 끝나 사다리가 다시 만들어질 때까지 새로 고치지 않습니다. + /// 디스크 읽기가 섞이면 표식을 지우고 다시 채움 표시를 남겨 기존 재예열 점검에 맡깁니다: 재예열은 여기서 부르지 않으며, 재예열이 끝나 사다리가 다시 만들어질 때까지 새로 고치지 않습니다. public TouchEnd? Refresh(GameSettings game, CancellationToken ct) { LadderMark? mark; @@ -97,8 +123,13 @@ public void Prune(IReadOnlySet live) log.Info($"{game.Name}: cache refresh stopped after {r.Bytes >> 20} MiB{Errors(r)}"); // 표식은 그대로 둠: 다음 틱에 다시 시도 break; default: - Forget(game.Name); - log.Info($"{game.Name}: cache refresh needs disk reads ({r.SlowPieces} slow pieces), left to the rewarm check{Errors(r)}"); + if (!NoteIncomplete(game.Name, r)) + { + log.Info($"{game.Name}: cache refresh was too slow without disk reads seen ({r.SlowPieces} slow pieces{Errors(r)}), left to the rewarm check"); + break; + } + + log.Info($"{game.Name}: cache refresh found data missing from memory {Missing(r)}, left to the rewarm check"); break; } @@ -136,6 +167,35 @@ private void SetMark(string game, IReadOnlyList ladder, TouchResult r) lock (_lock) { _marks[game] = mark; + _ = _missing.Remove(game); // 목록 전체가 메모리에 있음 + } + } + + // 표식을 지우고, 디스크 읽기를 실제로 본 경우에만 다시 채움 표시를 남깁니다 (표본이 warm 이어도 재예열 점검이 다시 읽게 함). 시간 기준이나 읽기 실패만으로 끝났다면 재예열로 고쳐지지 않으므로 표시 없이 점검의 표본 판정에 맡깁니다. 표시를 세웠으면 true. + private bool NoteIncomplete(string game, TouchResult r) + { + bool diskSeen = r.DiskBytes > 0; + lock (_lock) + { + _ = _marks.Remove(game); + if (diskSeen) + { + _ = _missing.Add(game); + } + } + + return diskSeen; + } + + /// 재예열이 읽기 전에 건너뛰어 아무것도 채우지 못했을 때 표시를 되돌려 다음 점검에서 다시 시도하게 합니다 (그 사이 표식이 섰다면 되돌리지 않음). + public void RestoreMissing(string game) + { + lock (_lock) + { + if (!_marks.ContainsKey(game)) + { + _ = _missing.Add(game); + } } } @@ -148,6 +208,9 @@ public void Forget(string game) } } + // 다시 채움 로그의 괄호: 느린 조각 수와 디스크에서 읽힌 양, 오류 수 + private static string Missing(TouchResult r) => $"({r.SlowPieces} slow pieces{(r.DiskBytes > 0 ? $", {r.DiskBytes >> 20} MiB read from disk" : string.Empty)}{(r.Errors > 0 ? $", {r.Errors} errors" : string.Empty)})"; + // 줄 끝에 붙이는 오류 수와 디스크에서 읽힌 것으로 본 양 private static string Errors(TouchResult r) => (r.Errors > 0 ? $", {r.Errors} errors" : string.Empty) + (r.DiskBytes > 0 ? $", {r.DiskBytes >> 20} MiB read from disk" : string.Empty); } diff --git a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs index 7cc4e66..e998a46 100644 --- a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs @@ -115,7 +115,7 @@ public void OrderDoesNothingWhenTheSettingIsOffOrThereIsNothingToTouch() [Theory] [InlineData(TouchEnd.Canceled, "ladder stopped after 100 MiB")] - [InlineData(TouchEnd.NeedsDiskReads, "ladder needs disk reads (3 slow pieces), skipped")] + [InlineData(TouchEnd.NeedsDiskReads, "ladder was too slow without disk reads seen (3 slow pieces), skipped")] public void AnInterruptedOrderLeavesNoMark(TouchEnd end, string text) { Solar solar = Make(); @@ -138,6 +138,85 @@ public void BytesSeenReadFromDiskAreAppendedToTheLogLine() Assert.EndsWith("MiB in 0.4 s, 20 MiB read from disk", _log.Lines.Single()); } + [Fact] + public void ALadderThatFindsDataMissingFlagsTheGameOnceAndItIsClearedWhenTaken() + { + Solar solar = Make(); + _result = new TouchResult(37 * Mib, 0.6, 36, 0, TouchEnd.NeedsDiskReads, 37 * Mib); + + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + + Assert.True(solar.HasMissing("a")); + Assert.Equal("Info: a: ladder found data missing from memory (36 slow pieces, 37 MiB read from disk), left to the rewarm check", _log.Lines.Single()); + Assert.True(solar.TakeMissing("a")); + Assert.False(solar.TakeMissing("a")); + Assert.False(solar.HasMissing("a")); + } + + [Fact] + public void ARefreshThatFindsDataMissingFlagsTheGameAndALaterCompleteLadderClearsIt() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _result = _result with { SlowPieces = 5, End = TouchEnd.NeedsDiskReads, DiskBytes = 32 * Mib }; + _ = solar.Refresh(Game("a"), CancellationToken.None); + Assert.True(solar.HasMissing("a")); + Assert.False(solar.HasMark("a")); + + _result = _result with { SlowPieces = 0, End = TouchEnd.Completed }; + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + + Assert.False(solar.HasMissing("a")); + Assert.True(solar.HasMark("a")); + } + + [Fact] + public void ALadderThatEndedSlowWithoutAnyDiskReadSeenClearsTheMarkButDoesNotFlagTheGame() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _result = new TouchResult(10 * Mib, 0.6, 5, 1, TouchEnd.NeedsDiskReads); + + _ = solar.Refresh(Game("a"), CancellationToken.None); + Assert.False(solar.HasMark("a")); + Assert.False(solar.HasMissing("a")); + Assert.Equal("Info: a: cache refresh was too slow without disk reads seen (5 slow pieces, 1 errors), left to the rewarm check", _log.Lines.Last()); + + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + Assert.False(solar.HasMissing("a")); + Assert.Equal("Info: a: ladder was too slow without disk reads seen (5 slow pieces, 1 errors), skipped", _log.Lines.Last()); + } + + [Fact] + public void RestoreMissingFlagsAgainOnlyWhileThereIsNoMark() + { + Solar solar = Make(); + solar.RestoreMissing("a"); + Assert.True(solar.HasMissing("a")); + + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // 사다리가 서서 표시가 지워짐 + solar.RestoreMissing("a"); + + Assert.False(solar.HasMissing("a")); + } + + [Fact] + public void ACanceledLadderDoesNotFlagAndPruneDropsTheFlagOfAGameThatLeftTheSettings() + { + Solar solar = Make(); + _result = _result with { End = TouchEnd.Canceled }; + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + Assert.False(solar.HasMissing("a")); + + _result = _result with { End = TouchEnd.NeedsDiskReads, DiskBytes = 32 * Mib }; + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + solar.Prune(new HashSet(["b"], StringComparer.OrdinalIgnoreCase)); + + Assert.False(solar.HasMissing("a")); + Assert.True(solar.HasMissing("b")); + } + [Fact] public void ErrorsAreAppendedToTheLogLine() { @@ -180,12 +259,12 @@ public void RefreshThatNeedsDiskReadsClearsTheMarkSoItIsNotRetriedUntilANewLadde { Solar solar = Make(); _ = solar.Order(Game("a"), Reads, CancellationToken.None); - _result = new TouchResult(10 * Mib, 0.6, 5, 1, TouchEnd.NeedsDiskReads); + _result = new TouchResult(10 * Mib, 0.6, 5, 1, TouchEnd.NeedsDiskReads, 32 * Mib); Assert.Equal(TouchEnd.NeedsDiskReads, solar.Refresh(Game("a"), CancellationToken.None)); Assert.False(solar.HasMark("a")); - Assert.Contains("a: cache refresh needs disk reads (5 slow pieces), left to the rewarm check, 1 errors", _log.Lines.Last()); + Assert.Contains("a: cache refresh found data missing from memory (5 slow pieces, 32 MiB read from disk, 1 errors), left to the rewarm check", _log.Lines.Last()); Assert.Empty(solar.Due([Game("a")], _now + TimeSpan.FromHours(1))); } @@ -278,11 +357,19 @@ private sealed class PlannedStreamer : IGameStreamer public Action? DuringStream { get; set; } + /// 참이면 스트림이 읽기 전에 예외로 실패함. + public bool Fail { 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.Add((game.Name, game.MaxStreamMib)); + if (Fail) + { + throw new IOException("read failed"); + } + long total = Math.Min(game.MaxStreamMib == 0 ? ZoneMib : game.MaxStreamMib, ZoneMib) * Mib; progress?.Invoke(0, total); DuringStream?.Invoke(); @@ -557,16 +644,16 @@ public void WhenTheRefreshNeedsDiskReadsNoRewarmIsCalledAndTheLadderReturnsOnlyA _touched.Clear(); _streamer.Calls.Clear(); GrowReuse(20000); - _result = _result with { SlowPieces = 4, End = TouchEnd.NeedsDiskReads }; + _result = _result with { SlowPieces = 4, End = TouchEnd.NeedsDiskReads, DiskBytes = 40 * Mib }; _now += TimeSpan.FromMinutes(5); AutoSettings auto = Auto(); p.Tick(auto); // 새로 고침이 디스크 읽기를 요구 -> 표식만 지우고 재예열은 부르지 않음 Assert.Single(_touched); Assert.False(p.Solar.HasMark("a")); - Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh needs disk reads (4 slow pieces), left to the rewarm check")); + Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh found data missing from memory (4 slow pieces, 40 MiB read from disk), left to the rewarm check")); - _result = _result with { SlowPieces = 0, End = TouchEnd.Completed }; + _result = _result with { SlowPieces = 0, DiskBytes = 0, End = TouchEnd.Completed }; for (int i = 0; i < 3; i++) { _now += TimeSpan.FromMinutes(2); @@ -577,12 +664,179 @@ public void WhenTheRefreshNeedsDiskReadsNoRewarmIsCalledAndTheLadderReturnsOnlyA Assert.Single(_touched); Assert.Empty(_streamer.Calls); - _now += TimeSpan.FromMinutes(6); // 첫 틱 뒤 10 분이 지나 재예열 점검 차례: 표본이 warm 이라 다시 사다리 + _now += TimeSpan.FromMinutes(6); // 첫 틱 뒤 10 분이 지나 재예열 점검 차례: 표본이 warm 이어도 다시 채움 표시 때문에 재예열 p.Tick(auto); - Assert.Equal(2, _touched.Count); + Assert.Single(_streamer.Calls); + Assert.Equal(2, _touched.Count); // 재예열 직후의 사다리 + Assert.True(p.Solar.HasMark("a")); + Assert.False(p.Solar.HasMissing("a")); + Assert.Contains(_log.Lines, l => l == "Info: a: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)"); + } + + // 예열 직후 사다리가 디스크 읽기를 만난 상태 (표식 없음, 다시 채움 표시 있음) 의 게임 하나, 첫 틱까지 지난 상태. + private Prewarmer FlaggedGame(out AutoSettings auto) + { + _result = _result with { SlowPieces = 3, End = TouchEnd.NeedsDiskReads, DiskBytes = 32 * Mib }; + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + Assert.True(p.Solar.HasMissing("a")); + Assert.False(p.Solar.HasMark("a")); + _touched.Clear(); + _streamer.Calls.Clear(); + auto = Auto(); + p.Tick(auto); // 첫 틱은 다음 점검 시각만 정함 + return p; + } + + [Fact] + public void ARewarmThatStillFindsDataMissingIsRepeatedOnlyOncePerCheckPeriod() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + Assert.Single(_streamer.Calls); // 표본은 warm 인데도 한 번 다시 읽음 + Assert.True(p.Solar.HasMissing("a")); // 재예열 뒤 사다리가 또 디스크 읽기를 만나 다시 섬 + + _now += TimeSpan.FromMinutes(2); + p.Tick(auto); + Assert.Single(_streamer.Calls); // 주기 안에서는 되풀이하지 않음 + + _now += TimeSpan.FromMinutes(9); + p.Tick(auto); + Assert.Equal(2, _streamer.Calls.Count); + } + + [Fact] + public void AfterASuccessfulRefillTheLadderMarkStandsAndLaterChecksDoNotPrewarmAgain() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + _result = _result with { SlowPieces = 0, DiskBytes = 0, End = TouchEnd.Completed }; + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + Assert.Single(_streamer.Calls); + Assert.True(p.Solar.HasMark("a")); + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + Assert.Single(_streamer.Calls); + } + + [Fact] + public void AChecksPulledInByAGameExitDoNotRefillBeforeRewarmMinutesHavePassedSinceTheLastPrewarm() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + p.SetAuto(auto); + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + Assert.Single(_streamer.Calls); + Assert.True(p.Solar.HasMissing("a")); // 재예열 뒤 사다리가 또 디스크 읽기를 만나 표시가 다시 섬 + + _now += TimeSpan.FromMinutes(1); + p.NoteGameExit(); // 다른 게임이 끝남: 점검을 2 분 뒤로 당김 + _now += TimeSpan.FromMinutes(2.5); + p.Tick(auto); + + Assert.Single(_streamer.Calls); // 마지막 예열 뒤 10 분 전이라 다시 읽지 않고 표시를 둠 + Assert.True(p.Solar.HasMissing("a")); + Assert.Contains(_log.Lines, l => l.Contains("a: rewarm check: part of the list is not in memory (found by the ladder), waiting until 10 min after the last prewarm")); + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + + Assert.Equal(2, _streamer.Calls.Count); + } + + [Fact] + public void AFailedRewarmIsNotRetriedByPulledInChecksBeforeRewarmMinutesHavePassed() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + p.SetAuto(auto); + _streamer.Fail = true; // 스트림이 돌지 않고 예외: 마지막 성공 예열 시각은 그대로 + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + Assert.Single(_streamer.Calls); + Assert.True(p.Solar.HasMissing("a")); + + _now += TimeSpan.FromMinutes(1); + p.NoteGameExit(); // 점검을 2 분 뒤로 당김 + _now += TimeSpan.FromMinutes(2.5); + p.Tick(auto); + Assert.Single(_streamer.Calls); // 마지막 시도 뒤 10 분 전이라 다시 시도하지 않음 + Assert.True(p.Solar.HasMissing("a")); + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + Assert.Equal(2, _streamer.Calls.Count); + } + + [Fact] + public void ARewarmThatIsSkippedBeforeReadingKeepsTheFlagForTheNextCheck() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + _avail = 100; // 메모리가 모자라 읽기 전에 건너뜀 + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + + Assert.Empty(_streamer.Calls); + Assert.True(p.Solar.HasMissing("a")); + + _avail = 20000; + _result = _result with { SlowPieces = 0, DiskBytes = 0, End = TouchEnd.Completed }; + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + + Assert.Single(_streamer.Calls); + Assert.False(p.Solar.HasMissing("a")); Assert.True(p.Solar.HasMark("a")); + } + + [Fact] + public void WhileTheFlagStandsTheLadderTickDoesNotTouchTheGame() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + GrowReuse(30000); + _now += TimeSpan.FromMinutes(5); + + p.Tick(auto); + + Assert.Empty(_touched); Assert.Empty(_streamer.Calls); + Assert.True(p.Solar.HasMissing("a")); + } + + [Fact] + public void TheFlagIsKeptWhileTheRewarmCheckConditionsBlockOrTheCheckIsOff() + { + Prewarmer p = FlaggedGame(out AutoSettings auto); + + _ = _running.Add("a"); // 게임 실행 중 + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + _ = _running.Remove("a"); + _diskBusy = true; // 디스크가 바쁨 + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + _diskBusy = false; + using (p.BeginTriggerWait([Game("b")])) // 트리거 대기 중 + { + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + } + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto with { RewarmMinutes = 0 }); // 점검 끔 + p.Tick(auto with { Enabled = false }); // 자동 모드 끔 + + Assert.Empty(_streamer.Calls); + Assert.True(p.Solar.HasMissing("a")); + + p.Tick(auto); // 끈 동안 다음 점검 시각이 지워져 다시 정함 + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); // 조건이 풀리면 한 번 채움 + Assert.Single(_streamer.Calls); } [Fact]