feat(cache): implement parallel cache loading and graceful flush at startup/shutdown
Introduces parallel cache loading using errgroup for zoom, certificate, and fuzzy caches with <100ms target. Adds startup timing instrumentation and graceful cache flushing before database closure to prevent data loss.
- commit
- ca5e83cbdacd66975c9baea2314f8644fa0fc61d
- author
- bnema · · 2025-10-23 19:32 UTC
- committer
- bnema · · 2025-10-24 17:33 UTC
- parent
- 6ddb3da
- signature
- unsigned
2 files · +204 -49
M internal/app/browser/browser.go +82 -6
| @@ -4,11 +4,13 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "database/sql" | |
| 6 | 6 | "embed" | |
| 7 | + | "fmt" | |
| 7 | 8 | "log" | |
| 8 | 9 | "os" | |
| 9 | 10 | "os/signal" | |
| 10 | 11 | "runtime" | |
| 11 | 12 | "syscall" | |
| 13 | + | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/dumber/internal/app/api" | |
| 14 | 16 | "github.com/bnema/dumber/internal/app/control" | |
| @@ -20,6 +22,7 @@ import ( | |||
| 20 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 21 | 23 | "github.com/bnema/dumber/internal/services" | |
| 22 | 24 | "github.com/bnema/dumber/pkg/webkit" | |
| 25 | + | "golang.org/x/sync/errgroup" | |
| 23 | 26 | ) | |
| 24 | 27 | ||
| 25 | 28 | // BrowserApp represents the main browser application | |
| @@ -62,6 +65,7 @@ type BrowserApp struct { | |||
| 62 | 65 | ||
| 63 | 66 | // Run starts the browser application | |
| 64 | 67 | func Run(assets embed.FS, version, commit, buildDate string) { | |
| 68 | + | startupStart := time.Now() | |
| 65 | 69 | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | |
| 66 | 70 | ||
| 67 | 71 | app := &BrowserApp{ | |
| @@ -79,6 +83,12 @@ func Run(assets embed.FS, version, commit, buildDate string) { | |||
| 79 | 83 | os.Exit(1) | |
| 80 | 84 | } | |
| 81 | 85 | ||
| 86 | + | startupElapsed := time.Since(startupStart) | |
| 87 | + | log.Printf("[startup] Application initialized in %v", startupElapsed) | |
| 88 | + | if startupElapsed > 500*time.Millisecond { | |
| 89 | + | log.Printf("[startup] WARNING: Startup took %v (target: <500ms)", startupElapsed) | |
| 90 | + | } | |
| 91 | + | ||
| 82 | 92 | app.Run() | |
| 83 | 93 | } | |
| 84 | 94 | ||
| @@ -137,10 +147,10 @@ func (app *BrowserApp) Initialize() error { | |||
| 137 | 147 | app.parserService = services.NewParserService(app.config, app.queries) | |
| 138 | 148 | app.browserService = services.NewBrowserService(app.config, app.queries) | |
| 139 | 149 | ||
| 140 | - | // Load zoom levels into in-memory cache for fast page transitions | |
| 141 | - | if err := app.browserService.LoadZoomCacheFromDB(context.Background()); err != nil { | |
| 142 | - | log.Printf("Warning: failed to load zoom cache: %v", err) | |
| 143 | - | // Non-fatal - zoom will fall back to defaults | |
| 150 | + | // Load all caches in parallel for fast startup (target: <100ms) | |
| 151 | + | if err := app.loadCachesParallel(context.Background()); err != nil { | |
| 152 | + | log.Printf("Warning: failed to load caches: %v", err) | |
| 153 | + | // Non-fatal - caches will fall back to defaults or DB queries | |
| 144 | 154 | } | |
| 145 | 155 | ||
| 146 | 156 | // Initialize handlers | |
| @@ -150,6 +160,57 @@ func (app *BrowserApp) Initialize() error { | |||
| 150 | 160 | return nil | |
| 151 | 161 | } | |
| 152 | 162 | ||
| 163 | + | // loadCachesParallel loads all caches in parallel for fast startup. | |
| 164 | + | // Uses errgroup to coordinate parallel loads and capture any errors. | |
| 165 | + | // Target: <100ms for all caches combined. | |
| 166 | + | func (app *BrowserApp) loadCachesParallel(ctx context.Context) error { | |
| 167 | + | startTime := time.Now() | |
| 168 | + | ||
| 169 | + | g, ctx := errgroup.WithContext(ctx) | |
| 170 | + | ||
| 171 | + | // Load zoom cache in parallel | |
| 172 | + | g.Go(func() error { | |
| 173 | + | if err := app.browserService.LoadZoomCacheFromDB(ctx); err != nil { | |
| 174 | + | log.Printf("[cache] Failed to load zoom cache: %v", err) | |
| 175 | + | return err | |
| 176 | + | } | |
| 177 | + | return nil | |
| 178 | + | }) | |
| 179 | + | ||
| 180 | + | // Load certificate validation cache in parallel | |
| 181 | + | g.Go(func() error { | |
| 182 | + | if err := app.browserService.LoadCertCacheFromDB(ctx); err != nil { | |
| 183 | + | log.Printf("[cache] Failed to load cert cache: %v", err) | |
| 184 | + | return err | |
| 185 | + | } | |
| 186 | + | return nil | |
| 187 | + | }) | |
| 188 | + | ||
| 189 | + | // Load fuzzy search cache in parallel for instant dmenu access | |
| 190 | + | g.Go(func() error { | |
| 191 | + | if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil { | |
| 192 | + | log.Printf("[cache] Failed to load fuzzy cache: %v", err) | |
| 193 | + | return err | |
| 194 | + | } | |
| 195 | + | return nil | |
| 196 | + | }) | |
| 197 | + | ||
| 198 | + | // Wait for all cache loads to complete | |
| 199 | + | if err := g.Wait(); err != nil { | |
| 200 | + | return fmt.Errorf("cache loading failed: %w", err) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | elapsed := time.Since(startTime) | |
| 204 | + | log.Printf("[cache] All caches loaded in %v", elapsed) | |
| 205 | + | ||
| 206 | + | // Warn if startup is slower than target | |
| 207 | + | if elapsed > 100*time.Millisecond { | |
| 208 | + | log.Printf("[cache] Warning: Cache loading took %v (target: <100ms)", elapsed) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | ||
| 153 | 214 | // Run executes the main browser loop | |
| 154 | 215 | func (app *BrowserApp) Run() { | |
| 155 | 216 | defer app.cleanup() | |
| @@ -218,6 +279,16 @@ func (app *BrowserApp) cleanup() { | |||
| 218 | 279 | app.workspace = nil | |
| 219 | 280 | } | |
| 220 | 281 | ||
| 282 | + | // Flush all caches to ensure pending writes complete before database closes | |
| 283 | + | if app.browserService != nil { | |
| 284 | + | log.Printf("Flushing all caches...") | |
| 285 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 286 | + | defer cancel() | |
| 287 | + | if err := app.browserService.FlushAllCaches(ctx); err != nil { | |
| 288 | + | log.Printf("Warning: failed to flush caches: %v", err) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 221 | 292 | // Close database with WAL checkpoint | |
| 222 | 293 | if app.database != nil { | |
| 223 | 294 | log.Printf("Performing WAL checkpoint and closing database...") | |
| @@ -287,8 +358,13 @@ func (app *BrowserApp) runMainLoop() { | |||
| 287 | 358 | // MUST happen before cleanup() deferred call, which happens after this function returns | |
| 288 | 359 | if app.browserService != nil { | |
| 289 | 360 | log.Printf("Flushing pending history writes...") | |
| 290 | - | app.browserService.FlushHistoryQueue() | |
| 291 | - | log.Printf("History queue flushed successfully") | |
| 361 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 362 | + | defer cancel() | |
| 363 | + | if err := app.browserService.FlushHistoryQueue(ctx); err != nil { | |
| 364 | + | log.Printf("Warning: history queue flush incomplete: %v", err) | |
| 365 | + | } else { | |
| 366 | + | log.Printf("History queue flushed successfully") | |
| 367 | + | } | |
| 292 | 368 | } | |
| 293 | 369 | } else { | |
| 294 | 370 | log.Printf("Not entering GUI loop (non-CGO build)") | |
| @@ -4,11 +4,13 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "embed" | 6 | "embed" | ||
| 7 | + | "fmt" | |||
| 7 | "log" | 8 | "log" | ||
| 8 | "os" | 9 | "os" | ||
| 9 | "os/signal" | 10 | "os/signal" | ||
| 10 | "runtime" | 11 | "runtime" | ||
| 11 | "syscall" | 12 | "syscall" | ||
| 13 | + | "time" | |||
| 12 | 14 | ||||
| 13 | "github.com/bnema/dumber/internal/app/api" | 15 | "github.com/bnema/dumber/internal/app/api" | ||
| 14 | "github.com/bnema/dumber/internal/app/control" | 16 | "github.com/bnema/dumber/internal/app/control" | ||
| @@ -20,6 +22,7 @@ import ( | |||||
| 20 | "github.com/bnema/dumber/internal/logging" | 22 | "github.com/bnema/dumber/internal/logging" | ||
| 21 | "github.com/bnema/dumber/internal/services" | 23 | "github.com/bnema/dumber/internal/services" | ||
| 22 | "github.com/bnema/dumber/pkg/webkit" | 24 | "github.com/bnema/dumber/pkg/webkit" | ||
| 25 | + | "golang.org/x/sync/errgroup" | |||
| 23 | ) | 26 | ) | ||
| 24 | 27 | ||||
| 25 | // BrowserApp represents the main browser application | 28 | // BrowserApp represents the main browser application | ||
| @@ -62,6 +65,7 @@ type BrowserApp struct { | |||||
| 62 | 65 | ||||
| 63 | // Run starts the browser application | 66 | // Run starts the browser application | ||
| 64 | func Run(assets embed.FS, version, commit, buildDate string) { | 67 | func Run(assets embed.FS, version, commit, buildDate string) { | ||
| 68 | + | startupStart := time.Now() | |||
| 65 | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | 69 | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | ||
| 66 | 70 | ||||
| 67 | app := &BrowserApp{ | 71 | app := &BrowserApp{ | ||
| @@ -79,6 +83,12 @@ func Run(assets embed.FS, version, commit, buildDate string) { | |||||
| 79 | os.Exit(1) | 83 | os.Exit(1) | ||
| 80 | } | 84 | } | ||
| 81 | 85 | ||||
| 86 | + | startupElapsed := time.Since(startupStart) | |||
| 87 | + | log.Printf("[startup] Application initialized in %v", startupElapsed) | |||
| 88 | + | if startupElapsed > 500*time.Millisecond { | |||
| 89 | + | log.Printf("[startup] WARNING: Startup took %v (target: <500ms)", startupElapsed) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 82 | app.Run() | 92 | app.Run() | ||
| 83 | } | 93 | } | ||
| 84 | 94 | ||||
| @@ -137,10 +147,10 @@ func (app *BrowserApp) Initialize() error { | |||||
| 137 | app.parserService = services.NewParserService(app.config, app.queries) | 147 | app.parserService = services.NewParserService(app.config, app.queries) | ||
| 138 | app.browserService = services.NewBrowserService(app.config, app.queries) | 148 | app.browserService = services.NewBrowserService(app.config, app.queries) | ||
| 139 | 149 | ||||
| 140 | - | // Load zoom levels into in-memory cache for fast page transitions | 150 | + | // Load all caches in parallel for fast startup (target: <100ms) |
| 141 | - | if err := app.browserService.LoadZoomCacheFromDB(context.Background()); err != nil { | 151 | + | if err := app.loadCachesParallel(context.Background()); err != nil { |
| 142 | - | log.Printf("Warning: failed to load zoom cache: %v", err) | 152 | + | log.Printf("Warning: failed to load caches: %v", err) |
| 143 | - | // Non-fatal - zoom will fall back to defaults | 153 | + | // Non-fatal - caches will fall back to defaults or DB queries |
| 144 | } | 154 | } | ||
| 145 | 155 | ||||
| 146 | // Initialize handlers | 156 | // Initialize handlers | ||
| @@ -150,6 +160,57 @@ func (app *BrowserApp) Initialize() error { | |||||
| 150 | return nil | 160 | return nil | ||
| 151 | } | 161 | } | ||
| 152 | 162 | ||||
| 163 | + | // loadCachesParallel loads all caches in parallel for fast startup. | |||
| 164 | + | // Uses errgroup to coordinate parallel loads and capture any errors. | |||
| 165 | + | // Target: <100ms for all caches combined. | |||
| 166 | + | func (app *BrowserApp) loadCachesParallel(ctx context.Context) error { | |||
| 167 | + | startTime := time.Now() | |||
| 168 | + | ||||
| 169 | + | g, ctx := errgroup.WithContext(ctx) | |||
| 170 | + | ||||
| 171 | + | // Load zoom cache in parallel | |||
| 172 | + | g.Go(func() error { | |||
| 173 | + | if err := app.browserService.LoadZoomCacheFromDB(ctx); err != nil { | |||
| 174 | + | log.Printf("[cache] Failed to load zoom cache: %v", err) | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | return nil | |||
| 178 | + | }) | |||
| 179 | + | ||||
| 180 | + | // Load certificate validation cache in parallel | |||
| 181 | + | g.Go(func() error { | |||
| 182 | + | if err := app.browserService.LoadCertCacheFromDB(ctx); err != nil { | |||
| 183 | + | log.Printf("[cache] Failed to load cert cache: %v", err) | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | return nil | |||
| 187 | + | }) | |||
| 188 | + | ||||
| 189 | + | // Load fuzzy search cache in parallel for instant dmenu access | |||
| 190 | + | g.Go(func() error { | |||
| 191 | + | if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil { | |||
| 192 | + | log.Printf("[cache] Failed to load fuzzy cache: %v", err) | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | return nil | |||
| 196 | + | }) | |||
| 197 | + | ||||
| 198 | + | // Wait for all cache loads to complete | |||
| 199 | + | if err := g.Wait(); err != nil { | |||
| 200 | + | return fmt.Errorf("cache loading failed: %w", err) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | elapsed := time.Since(startTime) | |||
| 204 | + | log.Printf("[cache] All caches loaded in %v", elapsed) | |||
| 205 | + | ||||
| 206 | + | // Warn if startup is slower than target | |||
| 207 | + | if elapsed > 100*time.Millisecond { | |||
| 208 | + | log.Printf("[cache] Warning: Cache loading took %v (target: <100ms)", elapsed) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | ||||
| 153 | // Run executes the main browser loop | 214 | // Run executes the main browser loop | ||
| 154 | func (app *BrowserApp) Run() { | 215 | func (app *BrowserApp) Run() { | ||
| 155 | defer app.cleanup() | 216 | defer app.cleanup() | ||
| @@ -218,6 +279,16 @@ func (app *BrowserApp) cleanup() { | |||||
| 218 | app.workspace = nil | 279 | app.workspace = nil | ||
| 219 | } | 280 | } | ||
| 220 | 281 | ||||
| 282 | + | // Flush all caches to ensure pending writes complete before database closes | |||
| 283 | + | if app.browserService != nil { | |||
| 284 | + | log.Printf("Flushing all caches...") | |||
| 285 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 286 | + | defer cancel() | |||
| 287 | + | if err := app.browserService.FlushAllCaches(ctx); err != nil { | |||
| 288 | + | log.Printf("Warning: failed to flush caches: %v", err) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 221 | // Close database with WAL checkpoint | 292 | // Close database with WAL checkpoint | ||
| 222 | if app.database != nil { | 293 | if app.database != nil { | ||
| 223 | log.Printf("Performing WAL checkpoint and closing database...") | 294 | log.Printf("Performing WAL checkpoint and closing database...") | ||
| @@ -287,8 +358,13 @@ func (app *BrowserApp) runMainLoop() { | |||||
| 287 | // MUST happen before cleanup() deferred call, which happens after this function returns | 358 | // MUST happen before cleanup() deferred call, which happens after this function returns | ||
| 288 | if app.browserService != nil { | 359 | if app.browserService != nil { | ||
| 289 | log.Printf("Flushing pending history writes...") | 360 | log.Printf("Flushing pending history writes...") | ||
| 290 | - | app.browserService.FlushHistoryQueue() | 361 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 291 | - | log.Printf("History queue flushed successfully") | 362 | + | defer cancel() |
| 363 | + | if err := app.browserService.FlushHistoryQueue(ctx); err != nil { | |||
| 364 | + | log.Printf("Warning: history queue flush incomplete: %v", err) | |||
| 365 | + | } else { | |||
| 366 | + | log.Printf("History queue flushed successfully") | |||
| 367 | + | } | |||
| 292 | } | 368 | } | ||
| 293 | } else { | 369 | } else { | ||
| 294 | log.Printf("Not entering GUI loop (non-CGO build)") | 370 | log.Printf("Not entering GUI loop (non-CGO build)") | ||
M internal/services/browser.go +122 -43
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "sync" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/dumber/internal/cache" | |
| 15 | 16 | "github.com/bnema/dumber/internal/config" | |
| 16 | 17 | "github.com/bnema/dumber/internal/db" | |
| 17 | 18 | "github.com/bnema/dumber/pkg/webkit" | |
| @@ -47,9 +48,16 @@ type BrowserService struct { | |||
| 47 | 48 | webView *webkit.WebView | |
| 48 | 49 | initialURL string | |
| 49 | 50 | guiBundle string | |
| 50 | - | zoomCache sync.Map // In-memory cache: key = domain/URL, value = float64 zoom level | |
| 51 | - | historyQueue chan historyUpdate // Queue for batched history writes | |
| 52 | - | historyFlushDone chan bool // Signal when history flush is complete (buffered) | |
| 51 | + | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | |
| 52 | + | certCache *cache.CertValidationsCache // In-memory cache for certificate validations | |
| 53 | + | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu | |
| 54 | + | historyQueue chan historyUpdate // Queue for batched history writes | |
| 55 | + | historyFlushDone chan bool // Signal when history flush is complete (buffered) | |
| 56 | + | flushOnce sync.Once // Ensures FlushHistoryQueue only runs once | |
| 57 | + | ||
| 58 | + | // Fuzzy cache refresh tracking | |
| 59 | + | historySinceRefresh int // Count of history entries added since last cache refresh | |
| 60 | + | lastCacheRefresh time.Time // Timestamp of last fuzzy cache refresh | |
| 53 | 61 | } | |
| 54 | 62 | ||
| 55 | 63 | // ServiceName returns the service name for frontend binding | |
| @@ -81,14 +89,19 @@ type HistoryEntry struct { | |||
| 81 | 89 | // NewBrowserService creates a new BrowserService instance. | |
| 82 | 90 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | |
| 83 | 91 | s := &BrowserService{ | |
| 84 | - | config: cfg, | |
| 85 | - | dbQueries: queries, | |
| 86 | - | windowTitleUpdater: nil, | |
| 87 | - | webView: nil, | |
| 88 | - | initialURL: "", | |
| 89 | - | guiBundle: "", | |
| 90 | - | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |
| 91 | - | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |
| 92 | + | config: cfg, | |
| 93 | + | dbQueries: queries, | |
| 94 | + | windowTitleUpdater: nil, | |
| 95 | + | webView: nil, | |
| 96 | + | initialURL: "", | |
| 97 | + | guiBundle: "", | |
| 98 | + | zoomCache: cache.NewZoomCache(queries), | |
| 99 | + | certCache: cache.NewCertValidationsCache(queries), | |
| 100 | + | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | |
| 101 | + | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |
| 102 | + | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |
| 103 | + | historySinceRefresh: 0, | |
| 104 | + | lastCacheRefresh: time.Now(), // Initialize to now | |
| 92 | 105 | } | |
| 93 | 106 | ||
| 94 | 107 | // Start background batch processor for history writes | |
| @@ -419,7 +432,29 @@ func (s *BrowserService) processHistoryQueue() { | |||
| 419 | 432 | } | |
| 420 | 433 | ||
| 421 | 434 | log.Printf("Flushed %d history updates to database", len(batch)) | |
| 435 | + | ||
| 436 | + | // Track history entries for fuzzy cache refresh | |
| 437 | + | batchSize := len(batch) | |
| 422 | 438 | batch = batch[:0] // Clear batch | |
| 439 | + | ||
| 440 | + | // Smart fuzzy cache refresh: rebuild after N entries AND M minutes | |
| 441 | + | const minEntries = 10 // Minimum new entries before refresh | |
| 442 | + | const minInterval = 5 * time.Minute // Minimum time between refreshes | |
| 443 | + | ||
| 444 | + | s.historySinceRefresh += batchSize | |
| 445 | + | timeSinceRefresh := time.Since(s.lastCacheRefresh) | |
| 446 | + | ||
| 447 | + | if s.historySinceRefresh >= minEntries && timeSinceRefresh >= minInterval { | |
| 448 | + | log.Printf("[cache] Triggering async fuzzy cache refresh (%d new entries, %v since last refresh)", | |
| 449 | + | s.historySinceRefresh, timeSinceRefresh) | |
| 450 | + | ||
| 451 | + | // Async refresh in background (non-blocking) | |
| 452 | + | s.fuzzyCache.InvalidateAndRefresh(ctx) | |
| 453 | + | ||
| 454 | + | // Reset counters | |
| 455 | + | s.historySinceRefresh = 0 | |
| 456 | + | s.lastCacheRefresh = time.Now() | |
| 457 | + | } | |
| 423 | 458 | } | |
| 424 | 459 | ||
| 425 | 460 | for { | |
| @@ -443,11 +478,23 @@ func (s *BrowserService) processHistoryQueue() { | |||
| 443 | 478 | } | |
| 444 | 479 | ||
| 445 | 480 | // FlushHistoryQueue flushes any pending history writes and stops the processor. | |
| 446 | - | // Call this during graceful shutdown. | |
| 447 | - | func (s *BrowserService) FlushHistoryQueue() { | |
| 448 | - | close(s.historyQueue) | |
| 449 | - | <-s.historyFlushDone // Wait for completion signal | |
| 450 | - | log.Printf("History queue flushed and closed") | |
| 481 | + | // Call this during graceful shutdown. Safe to call multiple times. | |
| 482 | + | // Respects context cancellation with a timeout to prevent hanging. | |
| 483 | + | func (s *BrowserService) FlushHistoryQueue(ctx context.Context) error { | |
| 484 | + | var flushErr error | |
| 485 | + | s.flushOnce.Do(func() { | |
| 486 | + | close(s.historyQueue) | |
| 487 | + | ||
| 488 | + | // Wait for completion with context cancellation support | |
| 489 | + | select { | |
| 490 | + | case <-s.historyFlushDone: | |
| 491 | + | log.Printf("History queue flushed and closed") | |
| 492 | + | case <-ctx.Done(): | |
| 493 | + | flushErr = fmt.Errorf("history queue flush cancelled: %w", ctx.Err()) | |
| 494 | + | log.Printf("Warning: %v", flushErr) | |
| 495 | + | } | |
| 496 | + | }) | |
| 497 | + | return flushErr | |
| 451 | 498 | } | |
| 452 | 499 | ||
| 453 | 500 | // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500% | |
| @@ -493,18 +540,64 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||
| 493 | 540 | // LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup. | |
| 494 | 541 | // This eliminates database reads during page transitions. | |
| 495 | 542 | func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error { | |
| 496 | - | zoomLevels, err := s.dbQueries.ListZoomLevels(ctx) | |
| 497 | - | if err != nil { | |
| 543 | + | if err := s.zoomCache.Load(ctx); err != nil { | |
| 498 | 544 | return fmt.Errorf("failed to load zoom levels: %w", err) | |
| 499 | 545 | } | |
| 500 | 546 | ||
| 501 | - | loadedCount := 0 | |
| 502 | - | for _, zl := range zoomLevels { | |
| 503 | - | s.zoomCache.Store(zl.Domain, zl.ZoomFactor) | |
| 504 | - | loadedCount++ | |
| 547 | + | loadedCount := len(s.zoomCache.List()) | |
| 548 | + | log.Printf("Loaded %d zoom levels into cache", loadedCount) | |
| 549 | + | return nil | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | // LoadCertCacheFromDB loads all certificate validations from the database into the cache. | |
| 553 | + | // This is typically called once at startup for fast RAM-first access. | |
| 554 | + | func (s *BrowserService) LoadCertCacheFromDB(ctx context.Context) error { | |
| 555 | + | if err := s.certCache.Load(ctx); err != nil { | |
| 556 | + | return fmt.Errorf("failed to load certificate validations: %w", err) | |
| 505 | 557 | } | |
| 506 | 558 | ||
| 507 | - | log.Printf("Loaded %d zoom levels into cache", loadedCount) | |
| 559 | + | loadedCount := len(s.certCache.List()) | |
| 560 | + | log.Printf("Loaded %d certificate validations into cache", loadedCount) | |
| 561 | + | return nil | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | // LoadFuzzyCacheFromDB builds or loads the fuzzy search cache from database history. | |
| 565 | + | // This ensures dmenu has instant access to pre-built search structures. | |
| 566 | + | func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |
| 567 | + | // GetCache automatically loads from binary file if valid, or builds from DB | |
| 568 | + | _, err := s.fuzzyCache.GetCache(ctx) | |
| 569 | + | if err != nil { | |
| 570 | + | return fmt.Errorf("failed to load fuzzy cache: %w", err) | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | stats := s.fuzzyCache.Stats() | |
| 574 | + | log.Printf("Loaded fuzzy cache: %d entries, %d trigrams", | |
| 575 | + | stats.EntryCount, stats.TrigramCount) | |
| 576 | + | return nil | |
| 577 | + | } | |
| 578 | + | ||
| 579 | + | // FlushAllCaches flushes all in-memory caches to ensure pending writes complete. | |
| 580 | + | // This is typically called during shutdown to prevent data loss. | |
| 581 | + | func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |
| 582 | + | // Flush zoom cache | |
| 583 | + | if err := s.zoomCache.Flush(); err != nil { | |
| 584 | + | return fmt.Errorf("failed to flush zoom cache: %w", err) | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | // Flush certificate validation cache | |
| 588 | + | if err := s.certCache.Flush(); err != nil { | |
| 589 | + | return fmt.Errorf("failed to flush cert cache: %w", err) | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | // Save fuzzy cache to binary file for instant dmenu access | |
| 593 | + | s.fuzzyCache.OnApplicationExit(ctx) | |
| 594 | + | ||
| 595 | + | // Flush history queue (existing mechanism) | |
| 596 | + | if err := s.FlushHistoryQueue(ctx); err != nil { | |
| 597 | + | return fmt.Errorf("failed to flush history queue: %w", err) | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | log.Printf("All caches flushed successfully") | |
| 508 | 601 | return nil | |
| 509 | 602 | } | |
| 510 | 603 | ||
| @@ -518,18 +611,14 @@ func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, | |||
| 518 | 611 | key := zoomKeyFromURL(url) | |
| 519 | 612 | ||
| 520 | 613 | // Check cache first (fast RAM lookup) | |
| 521 | - | if cachedZoom, ok := s.zoomCache.Load(key); ok { | |
| 522 | - | if zoom, ok := cachedZoom.(float64); ok { | |
| 523 | - | return zoom, nil | |
| 524 | - | } | |
| 614 | + | if zoom, ok := s.zoomCache.Get(key); ok { | |
| 615 | + | return zoom, nil | |
| 525 | 616 | } | |
| 526 | 617 | ||
| 527 | 618 | // Fallback to exact URL record for backward compatibility (cache miss) | |
| 528 | 619 | if key != url { | |
| 529 | - | if cachedZoom, ok := s.zoomCache.Load(url); ok { | |
| 530 | - | if zoom, ok := cachedZoom.(float64); ok { | |
| 531 | - | return zoom, nil | |
| 532 | - | } | |
| 620 | + | if zoom, ok := s.zoomCache.Get(url); ok { | |
| 621 | + | return zoom, nil | |
| 533 | 622 | } | |
| 534 | 623 | } | |
| 535 | 624 | ||
| @@ -553,18 +642,8 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel | |||
| 553 | 642 | ||
| 554 | 643 | key := zoomKeyFromURL(url) | |
| 555 | 644 | ||
| 556 | - | // Update cache immediately (fast, synchronous) | |
| 557 | - | s.zoomCache.Store(key, zoomLevel) | |
| 558 | - | ||
| 559 | - | // Persist to database asynchronously (non-blocking) | |
| 560 | - | go func() { | |
| 561 | - | bgCtx := context.Background() | |
| 562 | - | if err := s.dbQueries.SetZoomLevel(bgCtx, key, zoomLevel); err != nil { | |
| 563 | - | log.Printf("Warning: failed to persist zoom level to DB for %s: %v", key, err) | |
| 564 | - | } | |
| 565 | - | }() | |
| 566 | - | ||
| 567 | - | return nil | |
| 645 | + | // Update cache immediately (fast, synchronous) and persist async | |
| 646 | + | return s.zoomCache.Set(key, zoomLevel) | |
| 568 | 647 | } | |
| 569 | 648 | ||
| 570 | 649 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "sync" | 12 | "sync" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/dumber/internal/cache" | |||
| 15 | "github.com/bnema/dumber/internal/config" | 16 | "github.com/bnema/dumber/internal/config" | ||
| 16 | "github.com/bnema/dumber/internal/db" | 17 | "github.com/bnema/dumber/internal/db" | ||
| 17 | "github.com/bnema/dumber/pkg/webkit" | 18 | "github.com/bnema/dumber/pkg/webkit" | ||
| @@ -47,9 +48,16 @@ type BrowserService struct { | |||||
| 47 | webView *webkit.WebView | 48 | webView *webkit.WebView | ||
| 48 | initialURL string | 49 | initialURL string | ||
| 49 | guiBundle string | 50 | guiBundle string | ||
| 50 | - | zoomCache sync.Map // In-memory cache: key = domain/URL, value = float64 zoom level | 51 | + | zoomCache *cache.ZoomCache // In-memory cache for zoom levels |
| 51 | - | historyQueue chan historyUpdate // Queue for batched history writes | 52 | + | certCache *cache.CertValidationsCache // In-memory cache for certificate validations |
| 52 | - | historyFlushDone chan bool // Signal when history flush is complete (buffered) | 53 | + | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu |
| 54 | + | historyQueue chan historyUpdate // Queue for batched history writes | |||
| 55 | + | historyFlushDone chan bool // Signal when history flush is complete (buffered) | |||
| 56 | + | flushOnce sync.Once // Ensures FlushHistoryQueue only runs once | |||
| 57 | + | ||||
| 58 | + | // Fuzzy cache refresh tracking | |||
| 59 | + | historySinceRefresh int // Count of history entries added since last cache refresh | |||
| 60 | + | lastCacheRefresh time.Time // Timestamp of last fuzzy cache refresh | |||
| 53 | } | 61 | } | ||
| 54 | 62 | ||||
| 55 | // ServiceName returns the service name for frontend binding | 63 | // ServiceName returns the service name for frontend binding | ||
| @@ -81,14 +89,19 @@ type HistoryEntry struct { | |||||
| 81 | // NewBrowserService creates a new BrowserService instance. | 89 | // NewBrowserService creates a new BrowserService instance. | ||
| 82 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | 90 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | ||
| 83 | s := &BrowserService{ | 91 | s := &BrowserService{ | ||
| 84 | - | config: cfg, | 92 | + | config: cfg, |
| 85 | - | dbQueries: queries, | 93 | + | dbQueries: queries, |
| 86 | - | windowTitleUpdater: nil, | 94 | + | windowTitleUpdater: nil, |
| 87 | - | webView: nil, | 95 | + | webView: nil, |
| 88 | - | initialURL: "", | 96 | + | initialURL: "", |
| 89 | - | guiBundle: "", | 97 | + | guiBundle: "", |
| 90 | - | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | 98 | + | zoomCache: cache.NewZoomCache(queries), |
| 91 | - | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | 99 | + | certCache: cache.NewCertValidationsCache(queries), |
| 100 | + | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | |||
| 101 | + | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |||
| 102 | + | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |||
| 103 | + | historySinceRefresh: 0, | |||
| 104 | + | lastCacheRefresh: time.Now(), // Initialize to now | |||
| 92 | } | 105 | } | ||
| 93 | 106 | ||||
| 94 | // Start background batch processor for history writes | 107 | // Start background batch processor for history writes | ||
| @@ -419,7 +432,29 @@ func (s *BrowserService) processHistoryQueue() { | |||||
| 419 | } | 432 | } | ||
| 420 | 433 | ||||
| 421 | log.Printf("Flushed %d history updates to database", len(batch)) | 434 | log.Printf("Flushed %d history updates to database", len(batch)) | ||
| 435 | + | ||||
| 436 | + | // Track history entries for fuzzy cache refresh | |||
| 437 | + | batchSize := len(batch) | |||
| 422 | batch = batch[:0] // Clear batch | 438 | batch = batch[:0] // Clear batch | ||
| 439 | + | ||||
| 440 | + | // Smart fuzzy cache refresh: rebuild after N entries AND M minutes | |||
| 441 | + | const minEntries = 10 // Minimum new entries before refresh | |||
| 442 | + | const minInterval = 5 * time.Minute // Minimum time between refreshes | |||
| 443 | + | ||||
| 444 | + | s.historySinceRefresh += batchSize | |||
| 445 | + | timeSinceRefresh := time.Since(s.lastCacheRefresh) | |||
| 446 | + | ||||
| 447 | + | if s.historySinceRefresh >= minEntries && timeSinceRefresh >= minInterval { | |||
| 448 | + | log.Printf("[cache] Triggering async fuzzy cache refresh (%d new entries, %v since last refresh)", | |||
| 449 | + | s.historySinceRefresh, timeSinceRefresh) | |||
| 450 | + | ||||
| 451 | + | // Async refresh in background (non-blocking) | |||
| 452 | + | s.fuzzyCache.InvalidateAndRefresh(ctx) | |||
| 453 | + | ||||
| 454 | + | // Reset counters | |||
| 455 | + | s.historySinceRefresh = 0 | |||
| 456 | + | s.lastCacheRefresh = time.Now() | |||
| 457 | + | } | |||
| 423 | } | 458 | } | ||
| 424 | 459 | ||||
| 425 | for { | 460 | for { | ||
| @@ -443,11 +478,23 @@ func (s *BrowserService) processHistoryQueue() { | |||||
| 443 | } | 478 | } | ||
| 444 | 479 | ||||
| 445 | // FlushHistoryQueue flushes any pending history writes and stops the processor. | 480 | // FlushHistoryQueue flushes any pending history writes and stops the processor. | ||
| 446 | - | // Call this during graceful shutdown. | 481 | + | // Call this during graceful shutdown. Safe to call multiple times. |
| 447 | - | func (s *BrowserService) FlushHistoryQueue() { | 482 | + | // Respects context cancellation with a timeout to prevent hanging. |
| 448 | - | close(s.historyQueue) | 483 | + | func (s *BrowserService) FlushHistoryQueue(ctx context.Context) error { |
| 449 | - | <-s.historyFlushDone // Wait for completion signal | 484 | + | var flushErr error |
| 450 | - | log.Printf("History queue flushed and closed") | 485 | + | s.flushOnce.Do(func() { |
| 486 | + | close(s.historyQueue) | |||
| 487 | + | ||||
| 488 | + | // Wait for completion with context cancellation support | |||
| 489 | + | select { | |||
| 490 | + | case <-s.historyFlushDone: | |||
| 491 | + | log.Printf("History queue flushed and closed") | |||
| 492 | + | case <-ctx.Done(): | |||
| 493 | + | flushErr = fmt.Errorf("history queue flush cancelled: %w", ctx.Err()) | |||
| 494 | + | log.Printf("Warning: %v", flushErr) | |||
| 495 | + | } | |||
| 496 | + | }) | |||
| 497 | + | return flushErr | |||
| 451 | } | 498 | } | ||
| 452 | 499 | ||||
| 453 | // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500% | 500 | // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500% | ||
| @@ -493,18 +540,64 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||||
| 493 | // LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup. | 540 | // LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup. | ||
| 494 | // This eliminates database reads during page transitions. | 541 | // This eliminates database reads during page transitions. | ||
| 495 | func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error { | 542 | func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error { | ||
| 496 | - | zoomLevels, err := s.dbQueries.ListZoomLevels(ctx) | 543 | + | if err := s.zoomCache.Load(ctx); err != nil { |
| 497 | - | if err != nil { | |||
| 498 | return fmt.Errorf("failed to load zoom levels: %w", err) | 544 | return fmt.Errorf("failed to load zoom levels: %w", err) | ||
| 499 | } | 545 | } | ||
| 500 | 546 | ||||
| 501 | - | loadedCount := 0 | 547 | + | loadedCount := len(s.zoomCache.List()) |
| 502 | - | for _, zl := range zoomLevels { | 548 | + | log.Printf("Loaded %d zoom levels into cache", loadedCount) |
| 503 | - | s.zoomCache.Store(zl.Domain, zl.ZoomFactor) | 549 | + | return nil |
| 504 | - | loadedCount++ | 550 | + | } |
| 551 | + | ||||
| 552 | + | // LoadCertCacheFromDB loads all certificate validations from the database into the cache. | |||
| 553 | + | // This is typically called once at startup for fast RAM-first access. | |||
| 554 | + | func (s *BrowserService) LoadCertCacheFromDB(ctx context.Context) error { | |||
| 555 | + | if err := s.certCache.Load(ctx); err != nil { | |||
| 556 | + | return fmt.Errorf("failed to load certificate validations: %w", err) | |||
| 505 | } | 557 | } | ||
| 506 | 558 | ||||
| 507 | - | log.Printf("Loaded %d zoom levels into cache", loadedCount) | 559 | + | loadedCount := len(s.certCache.List()) |
| 560 | + | log.Printf("Loaded %d certificate validations into cache", loadedCount) | |||
| 561 | + | return nil | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | // LoadFuzzyCacheFromDB builds or loads the fuzzy search cache from database history. | |||
| 565 | + | // This ensures dmenu has instant access to pre-built search structures. | |||
| 566 | + | func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |||
| 567 | + | // GetCache automatically loads from binary file if valid, or builds from DB | |||
| 568 | + | _, err := s.fuzzyCache.GetCache(ctx) | |||
| 569 | + | if err != nil { | |||
| 570 | + | return fmt.Errorf("failed to load fuzzy cache: %w", err) | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | stats := s.fuzzyCache.Stats() | |||
| 574 | + | log.Printf("Loaded fuzzy cache: %d entries, %d trigrams", | |||
| 575 | + | stats.EntryCount, stats.TrigramCount) | |||
| 576 | + | return nil | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | // FlushAllCaches flushes all in-memory caches to ensure pending writes complete. | |||
| 580 | + | // This is typically called during shutdown to prevent data loss. | |||
| 581 | + | func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |||
| 582 | + | // Flush zoom cache | |||
| 583 | + | if err := s.zoomCache.Flush(); err != nil { | |||
| 584 | + | return fmt.Errorf("failed to flush zoom cache: %w", err) | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | // Flush certificate validation cache | |||
| 588 | + | if err := s.certCache.Flush(); err != nil { | |||
| 589 | + | return fmt.Errorf("failed to flush cert cache: %w", err) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | // Save fuzzy cache to binary file for instant dmenu access | |||
| 593 | + | s.fuzzyCache.OnApplicationExit(ctx) | |||
| 594 | + | ||||
| 595 | + | // Flush history queue (existing mechanism) | |||
| 596 | + | if err := s.FlushHistoryQueue(ctx); err != nil { | |||
| 597 | + | return fmt.Errorf("failed to flush history queue: %w", err) | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | log.Printf("All caches flushed successfully") | |||
| 508 | return nil | 601 | return nil | ||
| 509 | } | 602 | } | ||
| 510 | 603 | ||||
| @@ -518,18 +611,14 @@ func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, | |||||
| 518 | key := zoomKeyFromURL(url) | 611 | key := zoomKeyFromURL(url) | ||
| 519 | 612 | ||||
| 520 | // Check cache first (fast RAM lookup) | 613 | // Check cache first (fast RAM lookup) | ||
| 521 | - | if cachedZoom, ok := s.zoomCache.Load(key); ok { | 614 | + | if zoom, ok := s.zoomCache.Get(key); ok { |
| 522 | - | if zoom, ok := cachedZoom.(float64); ok { | 615 | + | return zoom, nil |
| 523 | - | return zoom, nil | |||
| 524 | - | } | |||
| 525 | } | 616 | } | ||
| 526 | 617 | ||||
| 527 | // Fallback to exact URL record for backward compatibility (cache miss) | 618 | // Fallback to exact URL record for backward compatibility (cache miss) | ||
| 528 | if key != url { | 619 | if key != url { | ||
| 529 | - | if cachedZoom, ok := s.zoomCache.Load(url); ok { | 620 | + | if zoom, ok := s.zoomCache.Get(url); ok { |
| 530 | - | if zoom, ok := cachedZoom.(float64); ok { | 621 | + | return zoom, nil |
| 531 | - | return zoom, nil | |||
| 532 | - | } | |||
| 533 | } | 622 | } | ||
| 534 | } | 623 | } | ||
| 535 | 624 | ||||
| @@ -553,18 +642,8 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel | |||||
| 553 | 642 | ||||
| 554 | key := zoomKeyFromURL(url) | 643 | key := zoomKeyFromURL(url) | ||
| 555 | 644 | ||||
| 556 | - | // Update cache immediately (fast, synchronous) | 645 | + | // Update cache immediately (fast, synchronous) and persist async |
| 557 | - | s.zoomCache.Store(key, zoomLevel) | 646 | + | return s.zoomCache.Set(key, zoomLevel) |
| 558 | - | ||||
| 559 | - | // Persist to database asynchronously (non-blocking) | |||
| 560 | - | go func() { | |||
| 561 | - | bgCtx := context.Background() | |||
| 562 | - | if err := s.dbQueries.SetZoomLevel(bgCtx, key, zoomLevel); err != nil { | |||
| 563 | - | log.Printf("Warning: failed to persist zoom level to DB for %s: %v", key, err) | |||
| 564 | - | } | |||
| 565 | - | }() | |||
| 566 | - | ||||
| 567 | - | return nil | |||
| 568 | } | 647 | } | ||
| 569 | 648 | ||||
| 570 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | 649 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | ||