Files
QuoteForge/internal/services/sync/worker.go
Michael Chus be77256d4e Add background sync worker and complete local-first architecture
Implements automatic background synchronization every 5 minutes:
- Worker pushes pending changes to server (PushPendingChanges)
- Worker pulls new pricelists (SyncPricelistsIfNeeded)
- Graceful shutdown with context cancellation
- Automatic online/offline detection via DB ping

New files:
- internal/services/sync/worker.go - Background sync worker
- internal/services/local_configuration.go - Local-first CRUD
- internal/localdb/converters.go - MariaDB ↔ SQLite converters

Extended sync infrastructure:
- Pending changes queue (pending_changes table)
- Push/pull sync endpoints (/api/sync/push, /pending)
- ConfigurationGetter interface for handler compatibility
- LocalConfigurationService replaces ConfigurationService

All configuration operations now run through SQLite with automatic
background sync to MariaDB when online. Phase 2.5 nearly complete.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 22:17:00 +03:00

96 lines
2.0 KiB
Go

package sync
import (
"context"
"log/slog"
"time"
"gorm.io/gorm"
)
// Worker performs background synchronization at regular intervals
type Worker struct {
service *Service
db *gorm.DB
interval time.Duration
logger *slog.Logger
stopCh chan struct{}
}
// NewWorker creates a new background sync worker
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
return &Worker{
service: service,
db: db,
interval: interval,
logger: slog.Default(),
stopCh: make(chan struct{}),
}
}
// isOnline checks if the database connection is available
func (w *Worker) isOnline() bool {
sqlDB, err := w.db.DB()
if err != nil {
return false
}
return sqlDB.Ping() == nil
}
// Start begins the background sync loop in a goroutine
func (w *Worker) Start(ctx context.Context) {
w.logger.Info("starting background sync worker", "interval", w.interval)
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
// Run once immediately
w.runSync()
for {
select {
case <-ctx.Done():
w.logger.Info("background sync worker stopped by context")
return
case <-w.stopCh:
w.logger.Info("background sync worker stopped")
return
case <-ticker.C:
w.runSync()
}
}
}
// Stop gracefully stops the worker
func (w *Worker) Stop() {
w.logger.Info("stopping background sync worker")
close(w.stopCh)
}
// runSync performs a single sync iteration
func (w *Worker) runSync() {
// Check if online
if !w.isOnline() {
w.logger.Debug("offline, skipping background sync")
return
}
w.logger.Debug("running background sync")
// Push pending changes first
pushed, err := w.service.PushPendingChanges()
if err != nil {
w.logger.Warn("failed to push pending changes", "error", err)
} else if pushed > 0 {
w.logger.Info("pushed pending changes", "count", pushed)
}
// Then check for new pricelists
err = w.service.SyncPricelistsIfNeeded()
if err != nil {
w.logger.Warn("failed to sync pricelists", "error", err)
}
w.logger.Debug("background sync completed")
}