detached ca5e83c

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 (
44 "context"
55 "database/sql"
66 "embed"
7+ "fmt"
78 "log"
89 "os"
910 "os/signal"
1011 "runtime"
1112 "syscall"
13+ "time"
1214
1315 "github.com/bnema/dumber/internal/app/api"
1416 "github.com/bnema/dumber/internal/app/control"
@@ -20,6 +22,7 @@ import (
2022 "github.com/bnema/dumber/internal/logging"
2123 "github.com/bnema/dumber/internal/services"
2224 "github.com/bnema/dumber/pkg/webkit"
25+ "golang.org/x/sync/errgroup"
2326 )
2427
2528 // BrowserApp represents the main browser application
@@ -62,6 +65,7 @@ type BrowserApp struct {
6265
6366 // Run starts the browser application
6467 func Run(assets embed.FS, version, commit, buildDate string) {
68+ startupStart := time.Now()
6569 log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
6670
6771 app := &BrowserApp{
@@ -79,6 +83,12 @@ func Run(assets embed.FS, version, commit, buildDate string) {
7983 os.Exit(1)
8084 }
8185
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+
8292 app.Run()
8393 }
8494
@@ -137,10 +147,10 @@ func (app *BrowserApp) Initialize() error {
137147 app.parserService = services.NewParserService(app.config, app.queries)
138148 app.browserService = services.NewBrowserService(app.config, app.queries)
139149
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
144154 }
145155
146156 // Initialize handlers
@@ -150,6 +160,57 @@ func (app *BrowserApp) Initialize() error {
150160 return nil
151161 }
152162
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+
153214 // Run executes the main browser loop
154215 func (app *BrowserApp) Run() {
155216 defer app.cleanup()
@@ -218,6 +279,16 @@ func (app *BrowserApp) cleanup() {
218279 app.workspace = nil
219280 }
220281
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+
221292 // Close database with WAL checkpoint
222293 if app.database != nil {
223294 log.Printf("Performing WAL checkpoint and closing database...")
@@ -287,8 +358,13 @@ func (app *BrowserApp) runMainLoop() {
287358 // MUST happen before cleanup() deferred call, which happens after this function returns
288359 if app.browserService != nil {
289360 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+ }
292368 }
293369 } else {
294370 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 application28 // BrowserApp represents the main browser application
@@ -62,6 +65,7 @@ type BrowserApp struct {
62 65
63 // Run starts the browser application66 // 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 transitions150+ // 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 defaults153+ // Non-fatal - caches will fall back to defaults or DB queries
144 }154 }
145 155
146 // Initialize handlers156 // Initialize handlers
@@ -150,6 +160,57 @@ func (app *BrowserApp) Initialize() error {
150 return nil160 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 loop214 // 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 = nil279 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 checkpoint292 // 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 returns358 // 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 (
1212 "sync"
1313 "time"
1414
15+ "github.com/bnema/dumber/internal/cache"
1516 "github.com/bnema/dumber/internal/config"
1617 "github.com/bnema/dumber/internal/db"
1718 "github.com/bnema/dumber/pkg/webkit"
@@ -47,9 +48,16 @@ type BrowserService struct {
4748 webView *webkit.WebView
4849 initialURL string
4950 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
5361 }
5462
5563 // ServiceName returns the service name for frontend binding
@@ -81,14 +89,19 @@ type HistoryEntry struct {
8189 // NewBrowserService creates a new BrowserService instance.
8290 func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
8391 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
92105 }
93106
94107 // Start background batch processor for history writes
@@ -419,7 +432,29 @@ func (s *BrowserService) processHistoryQueue() {
419432 }
420433
421434 log.Printf("Flushed %d history updates to database", len(batch))
435+
436+ // Track history entries for fuzzy cache refresh
437+ batchSize := len(batch)
422438 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+ }
423458 }
424459
425460 for {
@@ -443,11 +478,23 @@ func (s *BrowserService) processHistoryQueue() {
443478 }
444479
445480 // 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
451498 }
452499
453500 // 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
493540 // LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup.
494541 // This eliminates database reads during page transitions.
495542 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 {
498544 return fmt.Errorf("failed to load zoom levels: %w", err)
499545 }
500546
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)
505557 }
506558
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")
508601 return nil
509602 }
510603
@@ -518,18 +611,14 @@ func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64,
518611 key := zoomKeyFromURL(url)
519612
520613 // 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
525616 }
526617
527618 // Fallback to exact URL record for backward compatibility (cache miss)
528619 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
533622 }
534623 }
535624
@@ -553,18 +642,8 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel
553642
554643 key := zoomKeyFromURL(url)
555644
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)
568647 }
569648
570649 // 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.WebView48 webView *webkit.WebView
48 initialURL string49 initialURL string
49 guiBundle string50 guiBundle string
50- zoomCache sync.Map // In-memory cache: key = domain/URL, value = float64 zoom level51+ zoomCache *cache.ZoomCache // In-memory cache for zoom levels
51- historyQueue chan historyUpdate // Queue for batched history writes52+ 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 binding63 // 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 updates98+ zoomCache: cache.NewZoomCache(queries),
91- historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking99+ 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 writes107 // 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 batch438 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 signal484+ 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 := 0547+ 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 nil601 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close