1package cache
2
3import (
4 "context"
5 "crypto/md5"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "github.com/bnema/dumber/internal/logging"
14)
15
16// Cache update constants
17const (
18 updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build
19 dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation
20 recentHistoryLimit = 20 // Number of recent entries to check for changes
21 exactMatchBonus = 0.95 // Score bonus for exact matches
22 dirPerm = 0755 // Directory permissions
23)
24
25// CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
26type CacheManager struct {
27 cache *DmenuFuzzyCache
28 config *CacheConfig
29 queries HistoryQuerier
30 mu sync.RWMutex
31 lastDBHash string // Hash of database content to detect changes
32 updating int32 // Atomic flag to prevent concurrent updates
33}
34
35// NewCacheManager creates a new cache manager.
36func NewCacheManager(queries HistoryQuerier, config *CacheConfig) *CacheManager {
37 if config == nil {
38 config = DefaultCacheConfig()
39 }
40
41 // Set default cache file path if not specified
42 if config.CacheFile == "" {
43 stateDir, err := config.GetStateDir()
44 if err != nil {
45 // Fallback to temp directory if state dir fails
46 config.CacheFile = filepath.Join(os.TempDir(), "dumber_fuzzy_cache.bin")
47 } else {
48 config.CacheFile = filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
49 }
50 }
51
52 cm := &CacheManager{
53 cache: &DmenuFuzzyCache{},
54 config: config,
55 queries: queries,
56 }
57
58 return cm
59}
60
61// GetCache returns the current cache, loading from filesystem or DB as needed.
62func (cm *CacheManager) GetCache(ctx context.Context) (*DmenuFuzzyCache, error) {
63 cm.mu.RLock()
64
65 // Check if we have a valid cache
66 if cm.cache.entryCount > 0 {
67 defer cm.mu.RUnlock()
68 return cm.cache, nil
69 }
70 cm.mu.RUnlock()
71
72 // Need to load cache
73 return cm.loadOrBuildCache(ctx)
74}
75
76// loadOrBuildCache loads cache from filesystem or builds from database.
77func (cm *CacheManager) loadOrBuildCache(ctx context.Context) (*DmenuFuzzyCache, error) {
78 cm.mu.Lock()
79 defer cm.mu.Unlock()
80
81 // Double-check after acquiring lock
82 if cm.cache.entryCount > 0 {
83 return cm.cache, nil
84 }
85
86 // Try loading from filesystem first
87 if cm.shouldLoadFromFile() {
88 if err := cm.cache.LoadFromBinary(cm.config.CacheFile); err == nil {
89 // Verify cache is still valid
90 if cm.isCacheValid(ctx) {
91 return cm.cache, nil
92 }
93 }
94 }
95
96 // Build fresh cache from database
97 return cm.buildCacheFromDB(ctx)
98}
99
100// shouldLoadFromFile determines if we should try loading from filesystem.
101func (cm *CacheManager) shouldLoadFromFile() bool {
102 if _, err := os.Stat(cm.config.CacheFile); os.IsNotExist(err) {
103 return false
104 }
105
106 // Check if file is valid and not too old
107 if !IsValidCacheFile(cm.config.CacheFile) {
108 return false
109 }
110
111 // Check file age
112 info, err := os.Stat(cm.config.CacheFile)
113 if err != nil {
114 return false
115 }
116
117 return time.Since(info.ModTime()) < cm.config.TTL
118}
119
120// isCacheValid checks if the cache is still valid by comparing DB hash.
121func (cm *CacheManager) isCacheValid(ctx context.Context) bool {
122 currentHash, err := cm.calculateDBHash(ctx)
123 if err != nil {
124 return false
125 }
126
127 return currentHash == cm.lastDBHash
128}
129
130// buildCacheFromDB builds a fresh cache from the database.
131func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, error) {
132 // Prevent concurrent builds
133 if !atomic.CompareAndSwapInt32(&cm.updating, 0, 1) {
134 // Another goroutine is building, wait for it
135 for atomic.LoadInt32(&cm.updating) == 1 {
136 time.Sleep(updateWaitIntervalMs * time.Millisecond)
137 }
138 return cm.cache, nil
139 }
140 defer atomic.StoreInt32(&cm.updating, 0)
141
142 startTime := time.Now()
143
144 // Get history from database
145 history, err := cm.queries.GetHistory(ctx, int64(cm.config.MaxEntries))
146 if err != nil {
147 return nil, fmt.Errorf("failed to get history: %w", err)
148 }
149
150 // Build new cache
151 newCache := &DmenuFuzzyCache{
152 version: CacheVersion,
153 lastModified: time.Now().Unix(),
154 entryCount: uint32(len(history)),
155 }
156
157 // Convert database entries to compact format
158 newCache.entries = make([]CompactEntry, len(history))
159 for i, entry := range history {
160 title := ""
161 if entry.Title.Valid {
162 title = entry.Title.String
163 }
164
165 visitCount := int64(0)
166 if entry.VisitCount.Valid {
167 visitCount = entry.VisitCount.Int64
168 }
169
170 lastVisited := time.Now()
171 if entry.LastVisited.Valid {
172 lastVisited = entry.LastVisited.Time
173 }
174
175 faviconURL := ""
176 if entry.FaviconUrl.Valid {
177 faviconURL = entry.FaviconUrl.String
178 }
179
180 newCache.entries[i] = NewCompactEntry(
181 entry.Url,
182 title,
183 faviconURL,
184 visitCount,
185 lastVisited,
186 )
187 }
188
189 // Build search indices
190 newCache.buildTrigramIndex()
191 newCache.buildPrefixTrie()
192 newCache.buildSortedIndex() // Pre-sort for O(1) getTopEntries
193
194 // Update current cache
195 cm.cache = newCache
196
197 // Update DB hash
198 cm.lastDBHash, _ = cm.calculateDBHash(ctx)
199
200 // Save to filesystem in background
201 go cm.saveToFilesystemAsync()
202
203 // Log cache timing to file only (not stdout) to avoid interfering with dmenu
204 if logger := logging.GetLogger(); logger != nil {
205 logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("built in %v with %d entries", time.Since(startTime), len(history)), "CACHE")
206 }
207 return cm.cache, nil
208}
209
210// InvalidateAndRefresh forces cache invalidation and refresh.
211// This should be called when we know the DB has changed (e.g., after adding history).
212func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) {
213 go func() {
214 cm.mu.Lock()
215 defer cm.mu.Unlock()
216
217 // Clear current cache to force rebuild
218 cm.cache = &DmenuFuzzyCache{}
219 cm.lastDBHash = ""
220
221 // Remove cache file
222 if err := os.Remove(cm.config.CacheFile); err != nil {
223 logging.Warn(fmt.Sprintf("failed to remove cache file %s: %v", cm.config.CacheFile, err))
224 }
225
226 // Build new cache
227 _, err := cm.buildCacheFromDB(ctx)
228 if err != nil {
229 logging.Warn(fmt.Sprintf("failed to refresh cache: %v", err))
230 }
231 }()
232}
233
234// OnApplicationExit should be called when the application is about to exit.
235// This triggers a background cache refresh for the next startup.
236func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
237 // Check if cache needs updating
238 if !cm.isCacheValid(ctx) {
239 // Refresh cache in background for next startup
240 go func() {
241 ctx, cancel := context.WithTimeout(context.Background(), dbHashTimeoutSec*time.Second)
242 defer cancel()
243
244 _, err := cm.buildCacheFromDB(ctx)
245 if err != nil {
246 logging.Warn(fmt.Sprintf("failed to refresh cache on exit: %v", err))
247 }
248 }()
249 }
250}
251
252// saveToFilesystemAsync saves the cache to filesystem in a background goroutine.
253func (cm *CacheManager) saveToFilesystemAsync() {
254 // Create cache directory if it doesn't exist
255 cacheDir := filepath.Dir(cm.config.CacheFile)
256 if err := os.MkdirAll(cacheDir, dirPerm); err != nil {
257 logging.Warn(fmt.Sprintf("failed to create cache directory: %v", err))
258 return
259 }
260
261 // Save to temporary file first, then atomic rename
262 tempFile := cm.config.CacheFile + ".tmp"
263 if err := cm.cache.SaveToBinary(tempFile); err != nil {
264 logging.Warn(fmt.Sprintf("failed to save cache: %v", err))
265 if err := os.Remove(tempFile); err != nil {
266 logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err))
267 }
268 return
269 }
270
271 // Atomic rename
272 if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
273 logging.Warn(fmt.Sprintf("failed to rename cache file: %v", err))
274 if err := os.Remove(tempFile); err != nil {
275 logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err))
276 }
277 }
278}
279
280// calculateDBHash creates a hash of the current database state to detect changes.
281func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
282 // Get a small sample of recent entries to create a fingerprint
283 recentHistory, err := cm.queries.GetHistory(ctx, recentHistoryLimit) // Just check recent entries
284 if err != nil {
285 return "", err
286 }
287
288 // Create hash from URLs, visit counts, and last visited times
289 hasher := md5.New()
290 for _, entry := range recentHistory {
291 hasher.Write([]byte(entry.Url))
292 if entry.VisitCount.Valid {
293 if _, err := fmt.Fprintf(hasher, "%d", entry.VisitCount.Int64); err != nil {
294 logging.Warn(fmt.Sprintf("failed to write visit count to hasher: %v", err))
295 }
296 }
297 if entry.LastVisited.Valid {
298 hasher.Write([]byte(entry.LastVisited.Time.Format(time.RFC3339)))
299 }
300 }
301
302 return fmt.Sprintf("%x", hasher.Sum(nil)), nil
303}
304
305// Search performs a fuzzy search using the cached data.
306func (cm *CacheManager) Search(ctx context.Context, query string) (*FuzzyResult, error) {
307 cache, err := cm.GetCache(ctx)
308 if err != nil {
309 return nil, err
310 }
311
312 return cache.Search(query, cm.config), nil
313}
314
315// GetTopEntries returns the top entries without a search query.
316func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) {
317 cache, err := cm.GetCache(ctx)
318 if err != nil {
319 return nil, err
320 }
321
322 return cache.getTopEntries(cm.config), nil
323}
324
325// Stats returns cache statistics.
326func (cm *CacheManager) Stats() CacheStats {
327 cm.mu.RLock()
328 defer cm.mu.RUnlock()
329
330 stats := CacheStats{
331 EntryCount: int(cm.cache.entryCount),
332 TrigramCount: len(cm.cache.trigramIndex),
333 LastModified: time.Unix(cm.cache.lastModified, 0),
334 }
335
336 if info, err := os.Stat(cm.config.CacheFile); err == nil {
337 stats.FileSize = info.Size()
338 stats.FileModTime = info.ModTime()
339 }
340
341 return stats
342}
343
344// CacheStats provides information about the cache state.
345type CacheStats struct {
346 EntryCount int // Number of cached entries
347 TrigramCount int // Number of trigrams in index
348 FileSize int64 // Size of cache file on disk
349 LastModified time.Time // When cache was last built
350 FileModTime time.Time // When cache file was last modified
351}
352
353// String returns a string representation of cache stats.
354func (cs CacheStats) String() string {
355 return fmt.Sprintf("Entries: %d, Trigrams: %d, File: %d bytes, Modified: %v",
356 cs.EntryCount, cs.TrigramCount, cs.FileSize, cs.FileModTime.Format("15:04:05"))
357}