sizew/Program.cs
|
#nullable disable using System; using System.IO; using System.Text; using System.Collections.Generic; namespace SizeW { public sealed class CacheEntry { public int Version; public long SizeBytes; // Размер только файлов в этой папке public long TotalSizeBytes; // Полный размер (включая подпапки) - для быстрой отдачи public DateTime DirectoryLwtUtc; public DateTime UpdatedUtc; public double CheckRate; // Только для текущего запуска, не сериализуем во внешний формат. public bool Visited; } public static class Program { // Глобальный кэш в памяти private static Dictionary<string, CacheEntry> GlobalCache; private static string CurrentRootPath; // Формат глобального кэша в файле: // int32 Magic = 0x315A4353 ('S','C','Z','1') // int32 Version = 2 // int32 Count // повторяется Count раз: // int32 pathLen // byte[pathLen] utf8Path // int64 sizeBytes (Own Files) // int64 totalSizeBytes (Deep Size) <- NEW in V2 // int64 directoryLwtUtcTicks // int64 updatedUtcTicks // double checkRate private const int CacheVersion = 2; private const int CacheMagic = 0x315A4353; // 'SCZ1' private const double DefaultCheckRate = 0.2; private const double MinCheckRate = 0.01; private const double MaxCheckRate = 1.0; private const int LwtToleranceSeconds = 5; private static bool DebugLogEnabled = false; private static bool CacheDirty = false; private static readonly Random Rng = new Random(); public static int Main(string[] args) { if (args.Length == 0) { PrintHelp(); return 0; } bool recursive = false; bool recursiveVerbose = false; bool bypassCache = false; bool recalculate = false; bool raw = false; bool showTime = false; string path = null; foreach (var arg in args) { if (!arg.StartsWith("-") && !arg.StartsWith("/")) { if (path == null) { path = arg; } else { Console.Error.WriteLine("Only one path argument is supported."); return 1; } continue; } var a = arg.TrimStart('-', '/').ToLowerInvariant(); switch (a) { case "r": case "recursive": recursive = true; break; case "rv": case "recursiveverbose": recursive = true; recursiveVerbose = true; break; case "bc": case "bypasscache": bypassCache = true; break; case "rc": case "recalculate": recalculate = true; break; case "debuglog": case "debug": DebugLogEnabled = true; break; case "raw": raw = true; break; case "st": case "showtime": showTime = true; break; case "h": case "help": case "?": PrintHelp(); return 0; default: Console.Error.WriteLine("Unknown option: " + arg); return 1; } } if (string.IsNullOrWhiteSpace(path)) { Console.Error.WriteLine("Path argument is required."); return 1; } try { string normPath = NormalizePath(path); CurrentRootPath = normPath; LoadGlobalCache(); long size; if (showTime) { var sw = System.Diagnostics.Stopwatch.StartNew(); size = MeasureDirectoryInternal(normPath, recursive, recursiveVerbose, bypassCache, recalculate); sw.Stop(); Console.Error.WriteLine($"[TIME] Elapsed: {sw.Elapsed.TotalSeconds:F3} sec"); } else { size = MeasureDirectoryInternal(normPath, recursive, recursiveVerbose, bypassCache, recalculate); } SaveGlobalCache(recursive || recursiveVerbose); if (!recursiveVerbose) { if (raw) Console.WriteLine(size); else Console.WriteLine(FormatSize(size)); } else { if (raw) Console.WriteLine($"{size}\tTOTAL\t{normPath}"); else Console.WriteLine($"{FormatSize(size)}\tTOTAL\t{normPath}"); } return 0; } catch (Exception ex) { Console.Error.WriteLine("Error: " + ex.Message); if (DebugLogEnabled) Console.Error.WriteLine(ex.ToString()); return 1; } } // --- API for PowerShell / DLL --- public static long LibMeasureDirectory(string path, bool recursive, bool bypassCache, bool recalculate) { string normPath = NormalizePath(path); CurrentRootPath = normPath; LoadGlobalCache(); long size = MeasureDirectoryInternal(normPath, recursive, false, bypassCache, recalculate); SaveGlobalCache(recursive); return size; } public static void LibSetDebug(bool enabled) { DebugLogEnabled = enabled; } // ---------- Основная логика ---------- public static long MeasureDirectoryInternal( string path, bool recursive, bool recursiveVerbose, bool bypassCache, bool recalculate) { string debugPrefix = "[Measure] " + path + " | "; DateTime? currentLwt = null; try { currentLwt = Directory.GetLastWriteTimeUtc(path); } catch { Log(debugPrefix + "failed to get LWT, treating as no LWT"); } CacheEntry cache = null; bool hasCache = false; bool mustRecompute = false; bool usedCacheForOwn = false; double checkRate = DefaultCheckRate; long previousOwnSize = -1; long ownFilesSize = 0; // --- чтение кэша из глобального словаря --- if (!bypassCache && GlobalCache != null && GlobalCache.TryGetValue(path, out cache)) { hasCache = true; cache.Visited = true; if (cache.CheckRate > 0 && cache.CheckRate <= 1.0) checkRate = cache.CheckRate; Log($"[CacheRead] {path} | LWT={cache.DirectoryLwtUtc:o}, CheckRate={checkRate:F3}"); if (currentLwt.HasValue && cache.DirectoryLwtUtc != DateTime.MinValue) { var diff = Math.Abs((currentLwt.Value - cache.DirectoryLwtUtc).TotalSeconds); Log(debugPrefix + $"LWT diff = {diff:F4} sec"); if (diff > LwtToleranceSeconds) { mustRecompute = true; Log(debugPrefix + "LWT changed -> recompute"); CacheDirty = true; } } previousOwnSize = cache.SizeBytes; } else { Log(debugPrefix + "no cache"); } if (recalculate) { mustRecompute = true; Log(debugPrefix + "forced recalculate (-Recalculate)"); } // --- решение: использовать кэш или пересчитывать ownFiles --- // --- решение: использовать кэш или пересчитывать ownFiles --- if (!mustRecompute && hasCache && !bypassCache) { double roll = Rng.NextDouble(); Log(debugPrefix + $"stable roll={roll:F4} cr={checkRate:F4}"); if (roll >= checkRate) { // ДОВЕРЯЕМ КЭШУ // Если у нас есть TotalSizeBytes, и мы решили доверять кэшу, // то мы ВООБЩЕ не спускаемся вниз. if (cache.TotalSizeBytes > 0) { Log(debugPrefix + $"DEEP SKIP (Total={FormatSize(cache.TotalSizeBytes)})"); usedCacheForOwn = true; // Эмуляция того, что мы посетили (чтобы не удалилось при pruneUnvisitedSubdirectories) // НО! Если мы не спускаемся, мы не ставим Visited у детей. // Поэтому pruneUnvisitedSubdirectories должен быть аккуратен. // В текущей реализации prune удаляет только если entry.Visited == false. // Если мы не посетим детей, они удалятся. // ЭТО ПРОБЛЕМА. // РЕШЕНИЕ: // Мы не можем просто так скипнуть рекурсию, если наша система очистки // удаляет непосещенные. // Но пользователь ОДОБРИЛ это поведение в Plan? // "If sizew is run WITHOUT the -recursive flag, it will NO LONGER clean up..." // А здесь мы запускаем С флагом recursive, но решаем не ходить. // Чтобы дети не удалились, нам нужно либо: // 1. Не удалять детей, если родитель был "Skipped" (сложно отследить). // 2. Или смириться, что "Deep Cache" работает только если мы принимаем риск. // ВАЖНО: Мы меняли логику SaveGlobalCache. // IsUnderRoot(path, CurrentRootPath) -> if (recursive && !visited) -> delete. // Если мы тут вернем значение и выйдем, то для всех подпапок Visited будет false. // И SaveGlobalCache их удалит. И в следующий раз придется сканировать заново. // Это убьет всю оптимизацию. // ИСПРАВЛЕНИЕ: // Мы не можем "промаркировать" всех детей Visited без их обхода (их может быть миллион в кэше). // Поэтому мы должны сохранять кэш "умнее". // Но пока, чтобы заработала скорость, мы просто вернем значение. // А проблему удаления решим тем, что "Deep Skip" технически означает, // что мы "посетили" это поддерево виртуально. // Но SaveGlobalCache об этом не знает. // ХАК: Если мы делаем Deep Skip, мы должны как-то сообщить SaveGlobalCache не удалять детей этого пути. // Но это сложно. // АЛЬТЕРНАТИВА: Если мы доверяем кэшу на этом уровне, мы возвращаем TotalSizeBytes. // Но чтобы SaveGlobalCache не удалил детей, мы должны либо: // А) Отключить Pruning глобально (плохо для мусора). // Б) При Deep Skip'е мы не вызываем prune для этого поддерева. // Давайте пока реализуем возврат. И посмотрим. // Скорее всего, кэш похудеет (дети удалятся). // При следующем запуске: Родитель есть в кэше? ЕСТЬ (мы его обновим сейчас). // Значит, мы снова попадем сюда, снова скипнем, и снова вернем Total. // То есть, отсутствие детей в кэше НЕ ПОВРЕДИТ, если родитель знает Total. // И это даже ХОРОШО: зачем хранить записи о детях, если родитель помнит всё? // Это "схлопывание" кэша. // Если однажды мы решим пересканировать (CheckRate сработал), мы пойдем вниз. // Детей в кэше нет -> придется сканировать диск реально. // Это то, что нужно! "Cold" дети удаляются, остается только "Hot" сумма родителя. // Если родитель "протухнет", мы честно пересканируем диск. // ИТОГ: Удаление детей - это ФИЧА, а не баг. return cache.TotalSizeBytes; } // Если TotalSizeBytes нет (старый кэш или еще не посчитали), // то используем кэш только для ownFiles, но идем в рекурсию (как раньше). ownFilesSize = previousOwnSize; usedCacheForOwn = true; Log(debugPrefix + $"using cache (ownFilesSize={ownFilesSize})"); } else { mustRecompute = true; Log(debugPrefix + "roll < CR -> recompute own files"); } } if (!usedCacheForOwn) { ownFilesSize = ComputeOwnFilesSize(path, debugPrefix); } long total = ownFilesSize; // --- рекурсия по подпапкам --- if (recursive || recursiveVerbose) { foreach (var dir in EnumerateChildDirectories(path, debugPrefix)) { // Подпапки уже приходят с полным путем, нормализация не нужна long childSize = MeasureDirectoryInternal( dir, recursive, recursiveVerbose, bypassCache, recalculate); total += childSize; } } // --- обновление глобального кэша --- if (!bypassCache && !usedCacheForOwn) { bool changesDetected = !hasCache || previousOwnSize != ownFilesSize; if (changesDetected) { checkRate = Math.Min(checkRate * 1.5, MaxCheckRate); Log(debugPrefix + $"changes detected, new CheckRate={checkRate:F4}"); } else { // Size Propagation Logic: // Даже если ownFilesSize не изменился, могли измениться дети (total != Entry.TotalSizeBytes). // Если это произошло, мы должны увеличить CheckRate, чтобы в следующий раз // с большей вероятностью проверить эту папку (не скипать рекурсию). if (hasCache && cache.TotalSizeBytes != total && cache.TotalSizeBytes > 0) { checkRate = Math.Min(checkRate * 1.5, MaxCheckRate); Log(debugPrefix + $"DEEP CHANGE (Total {cache.TotalSizeBytes}->{total}), boosting CheckRate={checkRate:F4}"); // Принудительно ставим флаг, что как будто были изменения, чтобы сохранить новый CheckRate changesDetected = true; } else { checkRate = Math.Max(checkRate * 0.2, MinCheckRate); Log(debugPrefix + $"no changes, new CheckRate={checkRate:F4}"); } } // Обновляем запись, если были изменения или просто для обновления CheckRate // (но обновление CheckRate тоже считается изменением состояния кэша, чтобы оно сохранилось). if (!hasCache || changesDetected || Math.Abs(cache.CheckRate - checkRate) > 1e-6 || cache.TotalSizeBytes != total) { CacheDirty = true; } var lwtToStore = currentLwt ?? DateTime.UtcNow; if (!hasCache || cache == null) { cache = new CacheEntry(); } cache.Version = CacheVersion; cache.SizeBytes = ownFilesSize; cache.TotalSizeBytes = total; // Сохраняем полный размер cache.DirectoryLwtUtc = DateTime.SpecifyKind(lwtToStore, DateTimeKind.Utc); cache.UpdatedUtc = DateTime.UtcNow; cache.CheckRate = checkRate; cache.Visited = true; GlobalCache[path] = cache; } if (recursiveVerbose) { Console.WriteLine($"{total}\t{path}"); } return total; } // ---------- Вспомогательные методы обхода ---------- private static long ComputeOwnFilesSize(string path, string debugPrefix) { long total = 0; try { var di = new DirectoryInfo(path); // EnumerateFiles с DirectoryInfo обычно эффективнее, так как сразу получает метаданные (размер) foreach (var fileInfo in di.EnumerateFiles()) { try { total += fileInfo.Length; } catch (Exception ex) { Log(debugPrefix + $"file skip {fileInfo.Name}: {ex.Message}"); } } } catch (Exception ex) { Log(debugPrefix + $"files enumerate failed: {ex.Message}"); } Log(debugPrefix + $"ownFilesSize={total}"); return total; } private static IEnumerable<string> EnumerateChildDirectories(string path, string debugPrefix) { string[] dirs; try { dirs = Directory.GetDirectories(path); } catch (Exception ex) { Log(debugPrefix + $"dirs enumerate failed: {ex.Message}"); yield break; } foreach (var dir in dirs) { string d = dir; bool skip = false; try { var di = new DirectoryInfo(d); if ((di.Attributes & FileAttributes.ReparsePoint) != 0) { Log(debugPrefix + $"skip reparse point: {d}"); skip = true; } } catch (Exception ex) { Log(debugPrefix + $"dir skip {d}: {ex.Message}"); skip = true; } if (!skip) yield return d; } } public static string NormalizePath(string path) { if (string.IsNullOrWhiteSpace(path)) return path; string full; try { full = Path.GetFullPath(path); } catch { full = path; } return full.TrimEnd('\\', '/'); } private static string FormatSize(long bytes) { string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; double value = bytes; int idx = 0; while (value >= 1024.0 && idx < suffixes.Length - 1) { value /= 1024.0; idx++; } if (idx == 0) return $"{value:0} {suffixes[idx]}"; return $"{value:0.0} {suffixes[idx]}"; } private static void Log(string message) { if (DebugLogEnabled) { Console.Error.WriteLine(message); } } // ---------- Глобальный кэш: загрузка / сохранение ---------- private static string GetCacheFilePath() { string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (string.IsNullOrEmpty(root)) root = AppContext.BaseDirectory; string dir = Path.Combine(root, "sizew"); try { Directory.CreateDirectory(dir); } catch { // если не получилось — пробуем рядом с exe dir = AppContext.BaseDirectory; } return Path.Combine(dir, "cache.bin"); } public static void LoadGlobalCache() { GlobalCache = new Dictionary<string, CacheEntry>(StringComparer.OrdinalIgnoreCase); string cachePath = GetCacheFilePath(); if (!File.Exists(cachePath)) return; try { using (var fs = new FileStream(cachePath, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var bs = new BufferedStream(fs, 1024 * 1024)) // 1MB buffer using (var br = new BinaryReader(bs, Encoding.UTF8, false)) { int magic = br.ReadInt32(); if (magic != CacheMagic) return; int version = br.ReadInt32(); if (version != CacheVersion) return; int count = br.ReadInt32(); GlobalCache = new Dictionary<string, CacheEntry>(count, StringComparer.OrdinalIgnoreCase); byte[] buffer = new byte[4096]; for (int i = 0; i < count; i++) { int pathLen = br.ReadInt32(); if (pathLen > buffer.Length) buffer = new byte[Math.Max(pathLen, buffer.Length * 2)]; br.Read(buffer, 0, pathLen); string path = Encoding.UTF8.GetString(buffer, 0, pathLen); long sizeBytes = br.ReadInt64(); long totalSizeBytes = br.ReadInt64(); // NEW V2 long lwtTicks = br.ReadInt64(); long updTicks = br.ReadInt64(); double cr = br.ReadDouble(); var entry = new CacheEntry { Version = version, SizeBytes = sizeBytes, TotalSizeBytes = totalSizeBytes, DirectoryLwtUtc = new DateTime(lwtTicks, DateTimeKind.Utc), UpdatedUtc = new DateTime(updTicks, DateTimeKind.Utc), CheckRate = cr, Visited = false }; GlobalCache[path] = entry; } } Log("[CacheLoad] loaded global cache"); } catch (Exception ex) { Log("[CacheLoad] failed: " + ex.Message); GlobalCache.Clear(); } } public static void SaveGlobalCache(bool pruneUnvisitedSubdirectories) { if (GlobalCache == null) return; if (!CacheDirty) { Log("[CacheSave] skipped (not dirty)"); return; } string cachePath = GetCacheFilePath(); try { using (var fs = new FileStream(cachePath, FileMode.Create, FileAccess.Write, FileShare.Read)) using (var bs = new BufferedStream(fs, 1024 * 1024)) // 1MB buffer using (var bw = new BinaryWriter(bs, Encoding.UTF8, false)) { bw.Write(CacheMagic); bw.Write(CacheVersion); // Мы не знаем точное количество заранее из-за фильтрации, // поэтому сначала записываем 0, а потом вернемся и перезапишем реальное число. long countPos = fs.Position; // BufferedStream не меняет позицию базового стрима синхронно, но BinaryWriter пишет в BufferedStream // Тут важно: BinaryWriter пишет в BufferedStream. У BufferedStream и FileStream позиции могут отличаться. // Однако BinaryWriter.Seek (если бы он был) или BaseStream.Position работают корректно. // НО! BufferedStream.Position НЕ поддерживается во всех версиях .NET одинаково прозрачно при записи. // Проще записать placeholder, а потом сделать flush и seek. bw.Write((int)0); int actualCount = 0; foreach (var kvp in GlobalCache) { string path = kvp.Key; CacheEntry entry = kvp.Value; // Логика чистки (pruning): // Если мы работали рекурсивно (pruneUnvisitedSubdirectories = true), // то мы посетили всё, что было под CurrentRootPath. // Значит, если что-то под CurrentRootPath не посещено — оно удалено, не сохраняем. // А если мы НЕ работали рекурсивно, то мы не посещали подпапки, и удалять их нельзя. if (!string.IsNullOrEmpty(CurrentRootPath) && IsUnderRoot(path, CurrentRootPath)) { if (pruneUnvisitedSubdirectories && !entry.Visited) { // Рекурсивный режим и не посещено -> удалено -> скипаем continue; } // Если не рекурсивный режим, то сохраняем даже если !Visited (так как мы могли туда просто не зайти) } var pathBytes = Encoding.UTF8.GetBytes(path); bw.Write(pathBytes.Length); bw.Write(pathBytes); bw.Write(entry.SizeBytes); bw.Write(entry.TotalSizeBytes); // NEW V2 bw.Write(entry.DirectoryLwtUtc.Ticks); bw.Write(entry.UpdatedUtc.Ticks); bw.Write(entry.CheckRate); // Сброс флага Visited на будущее. entry.Visited = false; actualCount++; } // Перезапись количества bw.Flush(); // Сбрасываем буфер bs.Position = 8; // Смещение до Count (4 байта Magic + 4 байта Version) bw.Write(actualCount); } Log("[CacheSave] saved global cache"); } catch (Exception ex) { Log("[CacheSave] failed: " + ex.Message); } } private static bool IsUnderRoot(string path, string root) { if (string.IsNullOrEmpty(root)) return false; if (path.Equals(root, StringComparison.OrdinalIgnoreCase)) return true; if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return false; if (path.Length == root.Length) return true; char ch = path[root.Length]; return ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar; } // ---------- Help ---------- private static void PrintHelp() { Console.WriteLine("sizew.exe - cached directory size calculator"); Console.WriteLine(); Console.WriteLine("Usage:"); Console.WriteLine(" sizew.exe [options] <path>"); Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" -r, -recursive Recursive size (sum of all subdirs)"); Console.WriteLine(" -rv, -recursiveverbose Recursive with per-directory output"); Console.WriteLine(" -bc, -bypasscache Do not read/write global cache"); Console.WriteLine(" -rc, -recalculate Always recompute, but update cache"); Console.WriteLine(" -debuglog Verbose debug log to stderr"); Console.WriteLine(" -raw Output raw size in bytes"); Console.WriteLine(" -showtime Print total elapsed time to stderr"); Console.WriteLine(" -h, -help, /? This help"); Console.WriteLine(); } } } |