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 Unverified
2B82207D370127A0The key is unknown
1 file · +133 -16
M internal/services/browser.go +133 -16
| @@ -5,11 +5,11 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "database/sql" | |
| 7 | 7 | "embed" | |
| 8 | - | "errors" | |
| 9 | 8 | "fmt" | |
| 10 | 9 | "log" | |
| 11 | 10 | "math" | |
| 12 | 11 | neturl "net/url" | |
| 12 | + | "sync" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/dumber/internal/config" | |
| @@ -33,6 +33,12 @@ type WindowTitleUpdater interface { | |||
| 33 | 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 | 42 | // BrowserService handles browser-related operations for the built-in browser. | |
| 37 | 43 | type BrowserService struct { | |
| 38 | 44 | config *config.Config | |
| @@ -41,6 +47,9 @@ type BrowserService struct { | |||
| 41 | 47 | webView *webkit.WebView | |
| 42 | 48 | initialURL string | |
| 43 | 49 | 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 | 55 | // ServiceName returns the service name for frontend binding | |
| @@ -71,14 +80,21 @@ type HistoryEntry struct { | |||
| 71 | 80 | ||
| 72 | 81 | // NewBrowserService creates a new BrowserService instance. | |
| 73 | 82 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | |
| 74 | - | return &BrowserService{ | |
| 83 | + | s := &BrowserService{ | |
| 75 | 84 | config: cfg, | |
| 76 | 85 | dbQueries: queries, | |
| 77 | 86 | windowTitleUpdater: nil, | |
| 78 | 87 | webView: nil, | |
| 79 | 88 | initialURL: "", | |
| 80 | 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 | 100 | // 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 | 377 | // recordHistory adds or updates a history entry. | |
| 378 | + | // Now queues the update for batched processing instead of immediate DB write. | |
| 362 | 379 | func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error { | |
| 363 | - | // Use AddOrUpdateHistory which handles both cases | |
| 364 | 380 | titleNull := sql.NullString{Valid: false} | |
| 365 | 381 | if title != "" { | |
| 366 | 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 | 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 | 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 | 511 | // GetZoomLevel retrieves the saved zoom level for a URL. | |
| 512 | + | // Now reads from in-memory cache instead of database for instant access. | |
| 413 | 513 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | |
| 414 | 514 | if url == "" { | |
| 415 | 515 | return s.config.DefaultZoom, nil | |
| 416 | 516 | } | |
| 417 | 517 | ||
| 418 | 518 | 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 | |
| 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, nil | |
| 536 | + | // No zoom setting found, return configured default | |
| 537 | + | return s.config.DefaultZoom, nil | |
| 434 | 538 | } | |
| 435 | 539 | ||
| 436 | 540 | // SetZoomLevel sets the zoom level for a URL. | |
| 541 | + | // Updates cache immediately (instant) and persists to DB asynchronously. | |
| 437 | 542 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | |
| 438 | 543 | if url == "" { | |
| 439 | 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 | 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 | 570 | // 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.Config | 44 | config *config.Config | ||
| @@ -41,6 +47,9 @@ type BrowserService struct { | |||||
| 41 | webView *webkit.WebView | 47 | webView *webkit.WebView | ||
| 42 | initialURL string | 48 | initialURL string | ||
| 43 | guiBundle string | 49 | 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 binding | 55 | // 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 interface | 100 | // 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, nil | 515 | 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, nil | 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 | |||
| 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, nil | 536 | + | // 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) | ||