Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions csharp/src/AsterCache.Service/Rewarm.Ladder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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");
}
Expand Down
50 changes: 47 additions & 3 deletions csharp/src/AsterCache.Service/Rewarm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +20,7 @@ public sealed partial class Rewarmer
private DateTimeOffset? _next;
private readonly object _checkLock = new();
private readonly Dictionary<string, string> _checkState = new(StringComparer.OrdinalIgnoreCase); // 게임별 마지막 재확인 결과 (로그를 바뀔 때만 남김)
private readonly Dictionary<string, DateTimeOffset> _missingTriedAt = new(StringComparer.OrdinalIgnoreCase); // 게임별로 사다리의 다시 채움 표시로 재예열을 마지막 시도한 시각 (성공·실패·건너뜀 모두), _checkLock 으로 보호, 메모리에만 둠

public Rewarmer(Prewarmer prewarm, PrewarmEnv env, ILog log, Func<DateTimeOffset> now)
{
Expand All @@ -40,6 +41,11 @@ public void SetGames(IReadOnlyList<GameSettings> games)
{
_checkState.Remove(gone);
}

foreach (string gone in _missingTriedAt.Keys.Where(k => !live.Contains(k)).ToList())
{
_missingTriedAt.Remove(gone);
}
}

_prewarm.Solar.Prune(live);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -223,14 +229,52 @@ 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))
{
NoteCheck(g, "recent-warm", $"{g.Name}: rewarm check skipped (prewarm is running or recently completed)");
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)
{
Expand Down
75 changes: 69 additions & 6 deletions csharp/src/AsterCache.Service/Solar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal sealed class Solar(PrewarmEnv env, ILog log, Func<DateTimeOffset> now)
{
private readonly object _lock = new();
private readonly Dictionary<string, LadderMark> _marks = new(StringComparer.OrdinalIgnoreCase); // 마지막으로 만든 사다리의 표식, 메모리에만 둠 (서비스를 다시 시작하면 없음), _lock 으로 보호
private readonly HashSet<string> _missing = new(StringComparer.OrdinalIgnoreCase); // 사다리가 목록의 일부가 캐시에 없음을 발견한 게임 (재예열 점검이 다시 채울 때까지), 메모리에만 둠, _lock 으로 보호

public bool Enabled => env.StandbyLadderEnabled();

Expand All @@ -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<string> live)
{
lock (_lock)
Expand All @@ -33,6 +52,8 @@ public void Prune(IReadOnlySet<string> live)
{
_ = _marks.Remove(gone);
}

_ = _missing.RemoveWhere(k => !live.Contains(k));
}
}

Expand Down Expand Up @@ -62,16 +83,21 @@ public void Prune(IReadOnlySet<string> 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;
}

return r.End;
}

/// 표식이 있는 게임의 사다리를 다시 만듭니다 (디스크 읽기 없음). 표식이 없거나 꺼져 있으면 null.
/// 디스크 읽기가 섞이면 표식을 지우고 기존 재예열 점검에 맡깁니다: 재예열은 여기서 부르지 않으며, 점검이 warm 으로 판정하거나 재예열이 끝나 사다리가 다시 만들어질 때까지 새로 고치지 않습니다.
/// 디스크 읽기가 섞이면 표식을 지우고 다시 채움 표시를 남겨 기존 재예열 점검에 맡깁니다: 재예열은 여기서 부르지 않으며, 재예열이 끝나 사다리가 다시 만들어질 때까지 새로 고치지 않습니다.
public TouchEnd? Refresh(GameSettings game, CancellationToken ct)
{
LadderMark? mark;
Expand All @@ -97,8 +123,13 @@ public void Prune(IReadOnlySet<string> 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;
}

Expand Down Expand Up @@ -136,6 +167,35 @@ private void SetMark(string game, IReadOnlyList<ReadOp> 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);
}
}
}

Expand All @@ -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);
}
Loading