From 477ab389c128d4d149132581411626613833e9c2 Mon Sep 17 00:00:00 2001 From: DO0OG Date: Sat, 14 Nov 2026 04:15:35 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EC=82=AC=EB=8B=A4=EB=A6=AC=EA=B0=80=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EC=97=90=20=EC=97=86=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=EC=9D=84=20=EB=B0=9C=EA=B2=AC=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=B1=84=EC=9A=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재예열 점검은 표본 8곳만 보므로 예열 목록의 일부가 캐시에서 밀려나 있어도 따뜻하다고 판정했습니다. 사다리는 목록 전체를 만지며 캐시에 없는 부분을 정확히 발견하지만, 발견하면 멈추고 재예열 점검에 맡길 뿐이었고, 재예열 점검은 같은 표본으로 다시 따뜻하다고 했습니다. 그래서 일부가 식은 상태가 남았고 10분마다 같은 일이 되풀이됐습니다. - 사다리가 디스크 읽기를 실제로 감지한 게임에 "다시 채워야 함" 표시를 남김 - 재예열 점검은 그 표시가 있는 게임을 표본 결과와 무관하게 다시 예열함 (예열 스트림은 캐시에 있는 부분을 건너뜀) - 표시로 인한 재예열은 게임별로 마지막 예열이나 마지막 시도 뒤 rewarm_minutes 가 지났을 때만 함 - 재예열이 읽기 전에 건너뛰어지면 표시를 유지하고, 실제로 읽었을 때만 지움 - 표시가 있는 동안에는 그 게임의 사다리를 다시 시도하지 않음 - 시간 기준이나 읽기 실패만으로 멈춘 경우에는 표시를 세우지 않음 Issues #182 --- .../src/AsterCache.Service/Rewarm.Ladder.cs | 4 +- csharp/src/AsterCache.Service/Rewarm.cs | 50 +++- csharp/src/AsterCache.Service/Solar.cs | 75 ++++- .../AsterCache.Tests/Service/SolarTests.cs | 270 +++++++++++++++++- 4 files changed, 380 insertions(+), 19 deletions(-) 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]