detached eba0292

perf(services): add in-memory zoom cache and batched history writes

commit
eba0292c1df98968f20d520265558e50fb628956
author
bnema · · 2025-10-23 21:52 UTC
committer
bnema · · 2025-10-24 17:33 UTC
parent
06907ef
signature
GPG Unverified2B82207D370127A0 The key is unknown
1 file · +133 -16
M internal/services/browser.go +133 -16
@@ -5,11 +5,11 @@ import (
55 "context"
66 "database/sql"
77 "embed"
8- "errors"
98 "fmt"
109 "log"
1110 "math"
1211 neturl "net/url"
12+ "sync"
1313 "time"
1414
1515 "github.com/bnema/dumber/internal/config"
@@ -33,6 +33,12 @@ type WindowTitleUpdater interface {
3333 SetTitle(title string)
3434 }
3535
36+// historyUpdate represents a pending history write operation
37+type historyUpdate struct {
38+ url string
39+ title sql.NullString
40+}
41+
3642 // BrowserService handles browser-related operations for the built-in browser.
3743 type BrowserService struct {
3844 config *config.Config
@@ -41,6 +47,9 @@ type BrowserService struct {
4147 webView *webkit.WebView
4248 initialURL string
4349 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 struct{} // Signal when history flush is complete
4453 }
4554
4655 // ServiceName returns the service name for frontend binding
@@ -71,14 +80,21 @@ type HistoryEntry struct {
7180
7281 // NewBrowserService creates a new BrowserService instance.
7382 func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
74- return &BrowserService{
83+ s := &BrowserService{
7584 config: cfg,
7685 dbQueries: queries,
7786 windowTitleUpdater: nil,
7887 webView: nil,
7988 initialURL: "",
8089 guiBundle: "",
90+ historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
91+ historyFlushDone: make(chan struct{}),
8192 }
93+
94+ // Start background batch processor for history writes
95+ go s.processHistoryQueue()
96+
97+ return s
8298 }
8399
84100 // SetWindowTitleUpdater sets the window title updater interface
@@ -359,14 +375,79 @@ func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]con
359375 }
360376
361377 // recordHistory adds or updates a history entry.
378+// Now queues the update for batched processing instead of immediate DB write.
362379 func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
363- // Use AddOrUpdateHistory which handles both cases
364380 titleNull := sql.NullString{Valid: false}
365381 if title != "" {
366382 titleNull = sql.NullString{String: title, Valid: true}
367383 }
368384
369- return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
385+ // Queue the history update (non-blocking with buffer)
386+ select {
387+ case s.historyQueue <- historyUpdate{url: url, title: titleNull}:
388+ // Successfully queued
389+ return nil
390+ default:
391+ // Queue full, write directly (fallback)
392+ log.Printf("Warning: history queue full, writing directly")
393+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
394+ }
395+}
396+
397+// processHistoryQueue processes batched history writes in the background.
398+// Flushes every 5 seconds or when a batch reaches 50 items.
399+func (s *BrowserService) processHistoryQueue() {
400+ ticker := time.NewTicker(5 * time.Second)
401+ defer ticker.Stop()
402+
403+ batch := make([]historyUpdate, 0, 50)
404+ const maxBatchSize = 50
405+
406+ flush := func() {
407+ if len(batch) == 0 {
408+ return
409+ }
410+
411+ ctx := context.Background()
412+ // Process all updates in the batch
413+ // Note: We can't use a transaction with sqlc-generated code easily,
414+ // but we can still batch the operations to reduce overhead
415+ for _, update := range batch {
416+ if err := s.dbQueries.AddOrUpdateHistory(ctx, update.url, update.title); err != nil {
417+ log.Printf("Warning: failed to write history for %s: %v", update.url, err)
418+ }
419+ }
420+
421+ log.Printf("Flushed %d history updates to database", len(batch))
422+ batch = batch[:0] // Clear batch
423+ }
424+
425+ for {
426+ select {
427+ case update, ok := <-s.historyQueue:
428+ if !ok {
429+ // Channel closed, flush remaining and exit
430+ flush()
431+ close(s.historyFlushDone)
432+ return
433+ }
434+ batch = append(batch, update)
435+ if len(batch) >= maxBatchSize {
436+ flush()
437+ }
438+
439+ case <-ticker.C:
440+ flush()
441+ }
442+ }
443+}
444+
445+// 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 flush to complete
450+ log.Printf("History queue flushed and closed")
370451 }
371452
372453 // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
@@ -409,31 +490,55 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er
409490 return s.setZoom(ctx, url, 1.0)
410491 }
411492
493+// LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup.
494+// This eliminates database reads during page transitions.
495+func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error {
496+ zoomLevels, err := s.dbQueries.ListZoomLevels(ctx)
497+ if err != nil {
498+ return fmt.Errorf("failed to load zoom levels: %w", err)
499+ }
500+
501+ loadedCount := 0
502+ for _, zl := range zoomLevels {
503+ s.zoomCache.Store(zl.Domain, zl.ZoomFactor)
504+ loadedCount++
505+ }
506+
507+ log.Printf("Loaded %d zoom levels into cache", loadedCount)
508+ return nil
509+}
510+
412511 // GetZoomLevel retrieves the saved zoom level for a URL.
512+// Now reads from in-memory cache instead of database for instant access.
413513 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
414514 if url == "" {
415515 return s.config.DefaultZoom, nil
416516 }
417517
418518 key := zoomKeyFromURL(url)
419- zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
420- if err != nil {
421- if errors.Is(err, sql.ErrNoRows) {
422- // Fallback to exact URL record if present (backward compatibility)
423- zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
424- if err2 == nil {
425- return zl, nil
519+
520+ // 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+ }
525+ }
526+
527+ // Fallback to exact URL record for backward compatibility (cache miss)
528+ if key != url {
529+ if cachedZoom, ok := s.zoomCache.Load(url); ok {
530+ if zoom, ok := cachedZoom.(float64); ok {
531+ return zoom, nil
426532 }
427- // No zoom setting found, return configured default
428- return s.config.DefaultZoom, nil
429533 }
430- return s.config.DefaultZoom, err
431534 }
432535
433- return zoomLevel, nil
536+ // No zoom setting found, return configured default
537+ return s.config.DefaultZoom, nil
434538 }
435539
436540 // SetZoomLevel sets the zoom level for a URL.
541+// Updates cache immediately (instant) and persists to DB asynchronously.
437542 func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
438543 if url == "" {
439544 return fmt.Errorf("URL cannot be empty")
@@ -447,7 +552,19 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel
447552 }
448553
449554 key := zoomKeyFromURL(url)
450- return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
555+
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
451568 }
452569
453570 // GetCurrentURL returns the current URL (this would be implemented by the frontend)
@@ -5,11 +5,11 @@ import (
5 "context"5 "context"
6 "database/sql"6 "database/sql"
7 "embed"7 "embed"
8- "errors"
9 "fmt"8 "fmt"
10 "log"9 "log"
11 "math"10 "math"
12 neturl "net/url"11 neturl "net/url"
12+ "sync"
13 "time"13 "time"
14 14
15 "github.com/bnema/dumber/internal/config"15 "github.com/bnema/dumber/internal/config"
@@ -33,6 +33,12 @@ type WindowTitleUpdater interface {
33 SetTitle(title string)33 SetTitle(title string)
34 }34 }
35 35
36+// historyUpdate represents a pending history write operation
37+type historyUpdate struct {
38+ url string
39+ title sql.NullString
40+}
41+
36 // BrowserService handles browser-related operations for the built-in browser.42 // BrowserService handles browser-related operations for the built-in browser.
37 type BrowserService struct {43 type BrowserService struct {
38 config *config.Config44 config *config.Config
@@ -41,6 +47,9 @@ type BrowserService struct {
41 webView *webkit.WebView47 webView *webkit.WebView
42 initialURL string48 initialURL string
43 guiBundle string49 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 struct{} // Signal when history flush is complete
44 }53 }
45 54
46 // ServiceName returns the service name for frontend binding55 // ServiceName returns the service name for frontend binding
@@ -71,14 +80,21 @@ type HistoryEntry struct {
71 80
72 // NewBrowserService creates a new BrowserService instance.81 // NewBrowserService creates a new BrowserService instance.
73 func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {82 func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
74- return &BrowserService{83+ s := &BrowserService{
75 config: cfg,84 config: cfg,
76 dbQueries: queries,85 dbQueries: queries,
77 windowTitleUpdater: nil,86 windowTitleUpdater: nil,
78 webView: nil,87 webView: nil,
79 initialURL: "",88 initialURL: "",
80 guiBundle: "",89 guiBundle: "",
90+ historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
91+ historyFlushDone: make(chan struct{}),
81 }92 }
93+
94+ // Start background batch processor for history writes
95+ go s.processHistoryQueue()
96+
97+ return s
82 }98 }
83 99
84 // SetWindowTitleUpdater sets the window title updater interface100 // SetWindowTitleUpdater sets the window title updater interface
@@ -359,14 +375,79 @@ func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]con
359 }375 }
360 376
361 // recordHistory adds or updates a history entry.377 // recordHistory adds or updates a history entry.
378+// Now queues the update for batched processing instead of immediate DB write.
362 func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {379 func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
363- // Use AddOrUpdateHistory which handles both cases
364 titleNull := sql.NullString{Valid: false}380 titleNull := sql.NullString{Valid: false}
365 if title != "" {381 if title != "" {
366 titleNull = sql.NullString{String: title, Valid: true}382 titleNull = sql.NullString{String: title, Valid: true}
367 }383 }
368 384
369- return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)385+ // Queue the history update (non-blocking with buffer)
386+ select {
387+ case s.historyQueue <- historyUpdate{url: url, title: titleNull}:
388+ // Successfully queued
389+ return nil
390+ default:
391+ // Queue full, write directly (fallback)
392+ log.Printf("Warning: history queue full, writing directly")
393+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
394+ }
395+}
396+
397+// processHistoryQueue processes batched history writes in the background.
398+// Flushes every 5 seconds or when a batch reaches 50 items.
399+func (s *BrowserService) processHistoryQueue() {
400+ ticker := time.NewTicker(5 * time.Second)
401+ defer ticker.Stop()
402+
403+ batch := make([]historyUpdate, 0, 50)
404+ const maxBatchSize = 50
405+
406+ flush := func() {
407+ if len(batch) == 0 {
408+ return
409+ }
410+
411+ ctx := context.Background()
412+ // Process all updates in the batch
413+ // Note: We can't use a transaction with sqlc-generated code easily,
414+ // but we can still batch the operations to reduce overhead
415+ for _, update := range batch {
416+ if err := s.dbQueries.AddOrUpdateHistory(ctx, update.url, update.title); err != nil {
417+ log.Printf("Warning: failed to write history for %s: %v", update.url, err)
418+ }
419+ }
420+
421+ log.Printf("Flushed %d history updates to database", len(batch))
422+ batch = batch[:0] // Clear batch
423+ }
424+
425+ for {
426+ select {
427+ case update, ok := <-s.historyQueue:
428+ if !ok {
429+ // Channel closed, flush remaining and exit
430+ flush()
431+ close(s.historyFlushDone)
432+ return
433+ }
434+ batch = append(batch, update)
435+ if len(batch) >= maxBatchSize {
436+ flush()
437+ }
438+
439+ case <-ticker.C:
440+ flush()
441+ }
442+ }
443+}
444+
445+// 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 flush to complete
450+ log.Printf("History queue flushed and closed")
370 }451 }
371 452
372 // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%453 // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
@@ -409,31 +490,55 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er
409 return s.setZoom(ctx, url, 1.0)490 return s.setZoom(ctx, url, 1.0)
410 }491 }
411 492
493+// LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup.
494+// This eliminates database reads during page transitions.
495+func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error {
496+ zoomLevels, err := s.dbQueries.ListZoomLevels(ctx)
497+ if err != nil {
498+ return fmt.Errorf("failed to load zoom levels: %w", err)
499+ }
500+
501+ loadedCount := 0
502+ for _, zl := range zoomLevels {
503+ s.zoomCache.Store(zl.Domain, zl.ZoomFactor)
504+ loadedCount++
505+ }
506+
507+ log.Printf("Loaded %d zoom levels into cache", loadedCount)
508+ return nil
509+}
510+
412 // GetZoomLevel retrieves the saved zoom level for a URL.511 // GetZoomLevel retrieves the saved zoom level for a URL.
512+// Now reads from in-memory cache instead of database for instant access.
413 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {513 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
414 if url == "" {514 if url == "" {
415 return s.config.DefaultZoom, nil515 return s.config.DefaultZoom, nil
416 }516 }
417 517
418 key := zoomKeyFromURL(url)518 key := zoomKeyFromURL(url)
419- zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)519+
420- if err != nil {520+ // Check cache first (fast RAM lookup)
421- if errors.Is(err, sql.ErrNoRows) {521+ if cachedZoom, ok := s.zoomCache.Load(key); ok {
422- // Fallback to exact URL record if present (backward compatibility)522+ if zoom, ok := cachedZoom.(float64); ok {
423- zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)523+ return zoom, nil
424- if err2 == nil {524+ }
425- return zl, nil525+ }
526+
527+ // Fallback to exact URL record for backward compatibility (cache miss)
528+ if key != url {
529+ if cachedZoom, ok := s.zoomCache.Load(url); ok {
530+ if zoom, ok := cachedZoom.(float64); ok {
531+ return zoom, nil
426 }532 }
427- // No zoom setting found, return configured default
428- return s.config.DefaultZoom, nil
429 }533 }
430- return s.config.DefaultZoom, err
431 }534 }
432 535
433- return zoomLevel, nil536+ // No zoom setting found, return configured default
537+ return s.config.DefaultZoom, nil
434 }538 }
435 539
436 // SetZoomLevel sets the zoom level for a URL.540 // SetZoomLevel sets the zoom level for a URL.
541+// Updates cache immediately (instant) and persists to DB asynchronously.
437 func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {542 func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
438 if url == "" {543 if url == "" {
439 return fmt.Errorf("URL cannot be empty")544 return fmt.Errorf("URL cannot be empty")
@@ -447,7 +552,19 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel
447 }552 }
448 553
449 key := zoomKeyFromURL(url)554 key := zoomKeyFromURL(url)
450- return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)555+
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
451 }568 }
452 569
453 // GetCurrentURL returns the current URL (this would be implemented by the frontend)570 // 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