mirror of
https://github.com/tw93/Mole.git
synced 2026-02-12 04:09:00 +00:00
fix(analyze): reuse recent cache and refresh stale results
This commit is contained in:
@@ -415,6 +415,221 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadCacheReusesRecentEntryAfterDirectoryChanges(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
target := filepath.Join(home, "recent-change-target")
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := scanResult{TotalSize: 5, TotalFiles: 1}
|
||||||
|
if err := saveCacheToDisk(target, result); err != nil {
|
||||||
|
t.Fatalf("saveCacheToDisk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath, err := getCachePath(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCachePath: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open cache: %v", err)
|
||||||
|
}
|
||||||
|
var entry cacheEntry
|
||||||
|
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||||
|
t.Fatalf("decode cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
// Make cache entry look recently scanned, but older than mod time grace.
|
||||||
|
entry.ModTime = time.Now().Add(-2 * time.Hour)
|
||||||
|
entry.ScanTime = time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
tmp := cachePath + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||||
|
t.Fatalf("encode tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if err := os.Rename(tmp, cachePath); err != nil {
|
||||||
|
t.Fatalf("rename tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
||||||
|
t.Fatalf("chtimes target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := loadCacheFromDisk(target); err != nil {
|
||||||
|
t.Fatalf("expected recent cache to be reused, got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCacheExpiresWhenModifiedAndReuseWindowPassed(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
target := filepath.Join(home, "reuse-window-target")
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := scanResult{TotalSize: 5, TotalFiles: 1}
|
||||||
|
if err := saveCacheToDisk(target, result); err != nil {
|
||||||
|
t.Fatalf("saveCacheToDisk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath, err := getCachePath(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCachePath: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open cache: %v", err)
|
||||||
|
}
|
||||||
|
var entry cacheEntry
|
||||||
|
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||||
|
t.Fatalf("decode cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
// Within overall 7-day TTL but beyond reuse window.
|
||||||
|
entry.ModTime = time.Now().Add(-48 * time.Hour)
|
||||||
|
entry.ScanTime = time.Now().Add(-(cacheReuseWindow + time.Hour))
|
||||||
|
|
||||||
|
tmp := cachePath + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||||
|
t.Fatalf("encode tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if err := os.Rename(tmp, cachePath); err != nil {
|
||||||
|
t.Fatalf("rename tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
||||||
|
t.Fatalf("chtimes target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := loadCacheFromDisk(target); err == nil {
|
||||||
|
t.Fatalf("expected cache load to fail after reuse window passes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadStaleCacheFromDiskAllowsRecentExpiredCache(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
target := filepath.Join(home, "stale-cache-target")
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := scanResult{TotalSize: 7, TotalFiles: 2}
|
||||||
|
if err := saveCacheToDisk(target, result); err != nil {
|
||||||
|
t.Fatalf("saveCacheToDisk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath, err := getCachePath(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCachePath: %v", err)
|
||||||
|
}
|
||||||
|
file, err := os.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open cache: %v", err)
|
||||||
|
}
|
||||||
|
var entry cacheEntry
|
||||||
|
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||||
|
t.Fatalf("decode cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
// Expired for normal cache validation but still inside stale fallback window.
|
||||||
|
entry.ModTime = time.Now().Add(-48 * time.Hour)
|
||||||
|
entry.ScanTime = time.Now().Add(-48 * time.Hour)
|
||||||
|
|
||||||
|
tmp := cachePath + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||||
|
t.Fatalf("encode tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if err := os.Rename(tmp, cachePath); err != nil {
|
||||||
|
t.Fatalf("rename tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
|
||||||
|
t.Fatalf("chtimes target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := loadCacheFromDisk(target); err == nil {
|
||||||
|
t.Fatalf("expected normal cache load to fail")
|
||||||
|
}
|
||||||
|
if _, err := loadStaleCacheFromDisk(target); err != nil {
|
||||||
|
t.Fatalf("expected stale cache load to succeed, got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadStaleCacheFromDiskExpiresByStaleTTL(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
target := filepath.Join(home, "stale-cache-expired-target")
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := scanResult{TotalSize: 9, TotalFiles: 3}
|
||||||
|
if err := saveCacheToDisk(target, result); err != nil {
|
||||||
|
t.Fatalf("saveCacheToDisk: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachePath, err := getCachePath(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getCachePath: %v", err)
|
||||||
|
}
|
||||||
|
file, err := os.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open cache: %v", err)
|
||||||
|
}
|
||||||
|
var entry cacheEntry
|
||||||
|
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
|
||||||
|
t.Fatalf("decode cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
entry.ScanTime = time.Now().Add(-(staleCacheTTL + time.Hour))
|
||||||
|
|
||||||
|
tmp := cachePath + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
|
||||||
|
t.Fatalf("encode tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
if err := os.Rename(tmp, cachePath); err != nil {
|
||||||
|
t.Fatalf("rename tmp cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := loadStaleCacheFromDisk(target); err == nil {
|
||||||
|
t.Fatalf("expected stale cache load to fail after stale TTL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanPathPermissionError(t *testing.T) {
|
func TestScanPathPermissionError(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
lockedDir := filepath.Join(root, "locked")
|
lockedDir := filepath.Join(root, "locked")
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ func getCachePath(path string) (string, error) {
|
|||||||
return filepath.Join(cacheDir, filename), nil
|
return filepath.Join(cacheDir, filename), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
func loadRawCacheFromDisk(path string) (*cacheEntry, error) {
|
||||||
cachePath, err := getCachePath(path)
|
cachePath, err := getCachePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -200,23 +200,56 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
||||||
|
entry, err := loadRawCacheFromDisk(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.ModTime().After(entry.ModTime) {
|
scanAge := time.Since(entry.ScanTime)
|
||||||
// Allow grace window.
|
if scanAge > 7*24*time.Hour {
|
||||||
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
|
|
||||||
return nil, fmt.Errorf("cache expired: directory modified")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Since(entry.ScanTime) > 7*24*time.Hour {
|
|
||||||
return nil, fmt.Errorf("cache expired: too old")
|
return nil, fmt.Errorf("cache expired: too old")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &entry, nil
|
if info.ModTime().After(entry.ModTime) {
|
||||||
|
// Allow grace window.
|
||||||
|
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
|
||||||
|
// Directory mod time is noisy on macOS; reuse recent cache to avoid
|
||||||
|
// frequent full rescans while still forcing refresh for older entries.
|
||||||
|
if cacheReuseWindow <= 0 || scanAge > cacheReuseWindow {
|
||||||
|
return nil, fmt.Errorf("cache expired: directory modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadStaleCacheFromDisk loads cache without strict freshness checks.
|
||||||
|
// It is used for fast first paint before triggering a background refresh.
|
||||||
|
func loadStaleCacheFromDisk(path string) (*cacheEntry, error) {
|
||||||
|
entry, err := loadRawCacheFromDisk(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(entry.ScanTime) > staleCacheTTL {
|
||||||
|
return nil, fmt.Errorf("stale cache expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveCacheToDisk(path string, result scanResult) error {
|
func saveCacheToDisk(path string, result scanResult) error {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const (
|
|||||||
maxConcurrentOverview = 8
|
maxConcurrentOverview = 8
|
||||||
batchUpdateSize = 100
|
batchUpdateSize = 100
|
||||||
cacheModTimeGrace = 30 * time.Minute
|
cacheModTimeGrace = 30 * time.Minute
|
||||||
|
cacheReuseWindow = 24 * time.Hour
|
||||||
|
staleCacheTTL = 3 * 24 * time.Hour
|
||||||
|
|
||||||
// Worker pool limits.
|
// Worker pool limits.
|
||||||
minWorkers = 16
|
minWorkers = 16
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ type historyEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type scanResultMsg struct {
|
type scanResultMsg struct {
|
||||||
|
path string
|
||||||
result scanResult
|
result scanResult
|
||||||
err error
|
err error
|
||||||
|
stale bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type overviewSizeMsg struct {
|
type overviewSizeMsg struct {
|
||||||
@@ -369,9 +371,19 @@ func (m model) scanCmd(path string) tea.Cmd {
|
|||||||
Entries: cached.Entries,
|
Entries: cached.Entries,
|
||||||
LargeFiles: cached.LargeFiles,
|
LargeFiles: cached.LargeFiles,
|
||||||
TotalSize: cached.TotalSize,
|
TotalSize: cached.TotalSize,
|
||||||
TotalFiles: 0, // Cache doesn't store file count currently, minor UI limitation
|
TotalFiles: cached.TotalFiles,
|
||||||
}
|
}
|
||||||
return scanResultMsg{result: result, err: nil}
|
return scanResultMsg{path: path, result: result, err: nil}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stale, err := loadStaleCacheFromDisk(path); err == nil {
|
||||||
|
result := scanResult{
|
||||||
|
Entries: stale.Entries,
|
||||||
|
LargeFiles: stale.LargeFiles,
|
||||||
|
TotalSize: stale.TotalSize,
|
||||||
|
TotalFiles: stale.TotalFiles,
|
||||||
|
}
|
||||||
|
return scanResultMsg{path: path, result: result, err: nil, stale: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err, _ := scanGroup.Do(path, func() (any, error) {
|
v, err, _ := scanGroup.Do(path, func() (any, error) {
|
||||||
@@ -379,7 +391,7 @@ func (m model) scanCmd(path string) tea.Cmd {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanResultMsg{err: err}
|
return scanResultMsg{path: path, err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := v.(scanResult)
|
result := v.(scanResult)
|
||||||
@@ -390,7 +402,28 @@ func (m model) scanCmd(path string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}(path, result)
|
}(path, result)
|
||||||
|
|
||||||
return scanResultMsg{result: result, err: nil}
|
return scanResultMsg{path: path, result: result, err: nil}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) scanFreshCmd(path string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
v, err, _ := scanGroup.Do(path, func() (any, error) {
|
||||||
|
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return scanResultMsg{path: path, err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := v.(scanResult)
|
||||||
|
go func(p string, r scanResult) {
|
||||||
|
if err := saveCacheToDisk(p, r); err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
}(path, result)
|
||||||
|
|
||||||
|
return scanResultMsg{path: path, result: result}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +475,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case scanResultMsg:
|
case scanResultMsg:
|
||||||
|
if msg.path != "" && msg.path != m.path {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.scanning = false
|
m.scanning = false
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
||||||
@@ -457,7 +493,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.largeFiles = msg.result.LargeFiles
|
m.largeFiles = msg.result.LargeFiles
|
||||||
m.totalSize = msg.result.TotalSize
|
m.totalSize = msg.result.TotalSize
|
||||||
m.totalFiles = msg.result.TotalFiles
|
m.totalFiles = msg.result.TotalFiles
|
||||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
|
||||||
m.clampEntrySelection()
|
m.clampEntrySelection()
|
||||||
m.clampLargeSelection()
|
m.clampLargeSelection()
|
||||||
m.cache[m.path] = cacheSnapshot(m)
|
m.cache[m.path] = cacheSnapshot(m)
|
||||||
@@ -470,6 +505,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
_ = storeOverviewSize(path, size)
|
_ = storeOverviewSize(path, size)
|
||||||
}(m.path, m.totalSize)
|
}(m.path, m.totalSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg.stale {
|
||||||
|
m.status = fmt.Sprintf("Loaded cached data for %s, refreshing...", displayPath(m.path))
|
||||||
|
m.scanning = true
|
||||||
|
if m.totalFiles > 0 {
|
||||||
|
m.lastTotalFiles = m.totalFiles
|
||||||
|
}
|
||||||
|
atomic.StoreInt64(m.filesScanned, 0)
|
||||||
|
atomic.StoreInt64(m.dirsScanned, 0)
|
||||||
|
atomic.StoreInt64(m.bytesScanned, 0)
|
||||||
|
if m.currentPath != nil {
|
||||||
|
m.currentPath.Store("")
|
||||||
|
}
|
||||||
|
return m, tea.Batch(m.scanFreshCmd(m.path), tickCmd())
|
||||||
|
}
|
||||||
|
|
||||||
|
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||||
return m, nil
|
return m, nil
|
||||||
case overviewSizeMsg:
|
case overviewSizeMsg:
|
||||||
delete(m.overviewScanningSet, msg.Path)
|
delete(m.overviewScanningSet, msg.Path)
|
||||||
|
|||||||
Reference in New Issue
Block a user